打印箱唛修改 尾程垮桥事件提交
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||
import com.baiyue.common.security.JwtAuthenticationFilter;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -31,6 +33,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/**",
|
||||
@@ -42,9 +45,19 @@ public class AuthSecurityConfig {
|
||||
|
||||
@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));
|
||||
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("RSA JWK Set 只能包含 RSA 密钥");
|
||||
}
|
||||
return new NimbusJwtEncoder(new ImmutableJWKSet<>(set));
|
||||
} catch (java.text.ParseException exception) {
|
||||
throw new IllegalStateException("RSA JWK Set配置无效", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -8,7 +8,8 @@ import java.time.Duration;
|
||||
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "baiyue.security")
|
||||
public record AuthSecurityProperties(String jwtSecret, String issuer,
|
||||
public record AuthSecurityProperties(String jwtSecret, String issuer, String audience,
|
||||
String rsaJwkSet, String currentKid,
|
||||
Duration accessTokenTtl, Duration refreshTokenTtl,
|
||||
int maxLoginFailures, Duration lockDuration) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.auth.controller;
|
||||
|
||||
import com.baiyue.auth.config.AuthSecurityProperties;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
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;
|
||||
|
||||
/** 仅发布 RSA 公钥;轮换期配置当前与上一把公钥。 */
|
||||
@RestController
|
||||
public class JwksController {
|
||||
private final AuthSecurityProperties properties;
|
||||
public JwksController(AuthSecurityProperties properties) { this.properties = properties; }
|
||||
|
||||
@GetMapping("/api/auth/.well-known/jwks.json")
|
||||
public ResponseEntity<Map<String, Object>> jwks() throws Exception {
|
||||
JWKSet publicSet = JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet();
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
|
||||
.body(publicSet.toJSONObject());
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||
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;
|
||||
@@ -30,6 +30,7 @@ import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。
|
||||
@@ -108,7 +109,8 @@ public class AuthService {
|
||||
String jwtId = UUID.randomUUID().toString();
|
||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||
.issuer(properties.issuer()).id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
|
||||
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
|
||||
.subject(account.getId().toString())
|
||||
.claim("user_id", account.getId())
|
||||
.claim("username", account.getUsername())
|
||||
@@ -119,9 +121,15 @@ 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(
|
||||
JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
|
||||
signingHeader(), claims)).getTokenValue();
|
||||
|
||||
byte[] random = new byte[48];
|
||||
secureRandom.nextBytes(random);
|
||||
@@ -167,16 +175,21 @@ 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()).id(jwtId)
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer())
|
||||
.audience(List.of(properties.audience())).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();
|
||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
|
||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
|
||||
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
|
||||
authorization.getPermissions(), Duration.ofMinutes(15));
|
||||
return new ImpersonationTokenResponse("Bearer", accessToken, 900);
|
||||
@@ -184,6 +197,10 @@ public class AuthService {
|
||||
|
||||
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn) { }
|
||||
|
||||
private JwsHeader signingHeader() {
|
||||
return JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build();
|
||||
}
|
||||
|
||||
private static BizException invalidCredentials() {
|
||||
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
|
||||
}
|
||||
|
||||
@@ -31,10 +31,13 @@ spring:
|
||||
encode: UTF-8
|
||||
baiyue.security:
|
||||
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
|
||||
rsa-jwk-set: ${JWT_RSA_JWK_SET:}
|
||||
current-kid: ${JWT_CURRENT_KID:}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
|
||||
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
|
||||
issuer: ${JWT_ISSUER:auth-service}
|
||||
access-token-ttl: ${ACCESS_TOKEN_TTL:24h}
|
||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||
access-token-ttl: ${ACCESS_TOKEN_TTL:24m}
|
||||
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
|
||||
max-login-failures: ${MAX_LOGIN_FAILURES:5}
|
||||
lock-duration: ${LOGIN_LOCK_DURATION:15m}
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ class AuthTokenTtlConfigurationContractTest {
|
||||
Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(configuration)
|
||||
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:2h}")
|
||||
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:15m}")
|
||||
.contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}")
|
||||
.doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}");
|
||||
}
|
||||
|
||||
@@ -39,13 +39,14 @@ class AuthServiceSmallJwtTest {
|
||||
static class TestConfig {
|
||||
@Bean
|
||||
AuthSecurityProperties properties() {
|
||||
return new AuthSecurityProperties("unused", "auth-service", Duration.ofMinutes(30),
|
||||
return new AuthSecurityProperties("unused", "auth-service", "lastleg-api", "{}", "current-2026-08",
|
||||
Duration.ofMinutes(30),
|
||||
Duration.ofDays(7), 5, Duration.ofMinutes(10));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void signsIdentityOnlyJwtAndStoresAuthorizationInRedis() {
|
||||
void signsCrossRegionClaimsAndStoresSession() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(42L);
|
||||
account.setUsername("operator");
|
||||
@@ -72,8 +73,16 @@ 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("roles", "permissions", "authorities", "scope");
|
||||
.doesNotContainKeys("permissions", "scope");
|
||||
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
|
||||
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current-2026-08");
|
||||
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
||||
|
||||
@@ -9,6 +9,10 @@ public final class EventTypes {
|
||||
public static final String ORDER_APPROVED = "OrderApproved";
|
||||
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 IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
|
||||
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
|
||||
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
|
||||
public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted";
|
||||
public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected";
|
||||
public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged";
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** 头程装柜完成后交给海外尾程的不可变 V1 快照。 */
|
||||
public record FirstLegLoadingPlanCompletedV1(
|
||||
String loadingPlanId, String planNo, String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||
List<Order> orders) {
|
||||
|
||||
public record 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<Item> items) {
|
||||
}
|
||||
|
||||
public record 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) {
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FirstLegLoadingPlanCompletedV1Test {
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void v1PayloadRoundTripsWithoutLosingLargeIds() throws Exception {
|
||||
var item = new FirstLegLoadingPlanCompletedV1.Item("2088000000000000001", "SKU-1", "产品", "Product",
|
||||
2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), new BigDecimal("12.000"),
|
||||
new BigDecimal("0.200"), "2088000000000000002", "ONT8", "ONT8", "FBA-1", "SHIP-1", "MARK");
|
||||
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
|
||||
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
|
||||
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
|
||||
new BigDecimal("0.200"), List.of(item));
|
||||
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
|
||||
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
|
||||
String json = mapper.writeValueAsString(payload);
|
||||
assertThat(mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class)).isEqualTo(payload);
|
||||
assertThat(json).contains("2088000000000000003", "sourceInboundDetailId");
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -23,7 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
|
||||
* Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。
|
||||
*/
|
||||
@AutoConfiguration(
|
||||
after = HmacJwtDecoderAutoConfiguration.class,
|
||||
after = {HmacJwtDecoderAutoConfiguration.class, JwksJwtDecoderAutoConfiguration.class},
|
||||
before = SecurityAutoConfiguration.class)
|
||||
@EnableMethodSecurity
|
||||
public class BaiyueSecurityAutoConfiguration {
|
||||
@@ -47,11 +47,18 @@ public class BaiyueSecurityAutoConfiguration {
|
||||
@ConditionalOnMissingBean
|
||||
JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||
AuthenticationEntryPoint entryPoint,
|
||||
StringRedisTemplate redisTemplate,
|
||||
org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
|
||||
ObjectMapper objectMapper,
|
||||
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) {
|
||||
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint,
|
||||
new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix, objectMapper));
|
||||
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
|
||||
@Value("${baiyue.security.session-mode:redis}") String sessionMode) {
|
||||
AccessTokenSessionValidator validator;
|
||||
if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator();
|
||||
else {
|
||||
StringRedisTemplate template=redis.getIfAvailable();
|
||||
if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate");
|
||||
validator=new RedisAccessTokenSessionValidator(template,keyPrefix,objectMapper);
|
||||
}
|
||||
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** 海外模式直接使用已由 RS256/JWKS 验证的短时令牌授权快照。 */
|
||||
public class ClaimsAccessTokenSessionValidator implements AccessTokenSessionValidator {
|
||||
@Override public AccessTokenSession validate(Jwt jwt) {
|
||||
Long userId=number(jwt.getClaims().getOrDefault("userId",jwt.getClaims().get("user_id")));
|
||||
return new AccessTokenSession(1,userId,values(jwt.getClaims().get("roles")),
|
||||
values(jwt.getClaims().getOrDefault("authorities",jwt.getClaims().get("permissions"))));
|
||||
}
|
||||
private static Long number(Object value){ if(value instanceof Number n)return n.longValue(); try{return Long.valueOf(String.valueOf(value));}catch(Exception e){return null;} }
|
||||
private static Set<String> values(Object value){ Set<String> result=new LinkedHashSet<>(); if(value instanceof Iterable<?> it)it.forEach(v->result.add(String.valueOf(v))); else if(value!=null)for(String v:String.valueOf(value).split("[ ,]+"))if(!v.isBlank())result.add(v); return result; }
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ import java.nio.charset.StandardCharsets;
|
||||
* HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。
|
||||
*/
|
||||
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
|
||||
@ConditionalOnProperty(name = "baiyue.security.jwt-secret")
|
||||
@ConditionalOnProperty(name = "baiyue.security.jwt-mode", havingValue = "hmac", matchIfMissing = true)
|
||||
public class HmacJwtDecoderAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(JwtDecoder.class)
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 海外服务仅通过可轮换 JWKS 验证 RS256,不访问国内 Redis。 */
|
||||
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
|
||||
@ConditionalOnProperty(name="baiyue.security.jwt-mode",havingValue="jwks")
|
||||
public class JwksJwtDecoderAutoConfiguration {
|
||||
@Bean @ConditionalOnMissingBean(JwtDecoder.class)
|
||||
JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri}") String uri,
|
||||
@Value("${baiyue.security.issuer:auth-service}") String issuer,
|
||||
@Value("${baiyue.security.audience:lastleg-api}") String audience) {
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri)
|
||||
.jwsAlgorithm(SignatureAlgorithm.RS256).build();
|
||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||
JwtValidators.createDefaultWithIssuer(issuer),
|
||||
new JwtClaimValidator<List<String>>("aud", values -> values != null && values.contains(audience))));
|
||||
return decoder;
|
||||
}
|
||||
}
|
||||
@@ -23,20 +23,21 @@ public class JwtClaimsMapper {
|
||||
addRoles(permissions, session.roles());
|
||||
}
|
||||
|
||||
String appCode = jwt.getClaimAsString("app_code");
|
||||
String appCode = firstNonBlank(jwt.getClaimAsString("appCode"), jwt.getClaimAsString("app_code"));
|
||||
if (appCode != null && !appCode.isBlank()) {
|
||||
permissions.add("APP_" + appCode.toUpperCase(java.util.Locale.ROOT));
|
||||
}
|
||||
String username = firstNonBlank(jwt.getClaimAsString("username"),
|
||||
jwt.getClaimAsString("preferred_username"), jwt.getSubject());
|
||||
Long userId = longClaim(jwt, "user_id");
|
||||
Long userId = longClaim(jwt, "userId");
|
||||
if (userId == null) userId = longClaim(jwt, "user_id");
|
||||
AuthenticatedUser user = new AuthenticatedUser(
|
||||
userId == null ? longValue(jwt.getSubject()) : userId,
|
||||
longClaim(jwt, "tenant_id"),
|
||||
longClaimEither(jwt, "tenantId", "tenant_id"),
|
||||
longClaim(jwt, "operator_tenant_id"),
|
||||
longClaim(jwt, "membership_id"),
|
||||
username,
|
||||
jwt.getClaimAsString("app_code"),
|
||||
appCode,
|
||||
jwt.getClaimAsString("client_type"),
|
||||
permissions,
|
||||
Boolean.TRUE.equals(jwt.getClaim("impersonated")),
|
||||
@@ -76,6 +77,10 @@ public class JwtClaimsMapper {
|
||||
if (value instanceof String text) return longValue(text);
|
||||
return null;
|
||||
}
|
||||
private Long longClaimEither(Jwt jwt, String first, String second) {
|
||||
Long value = longClaim(jwt, first);
|
||||
return value == null ? longClaim(jwt, second) : value;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
for (String value : values) if (value != null && !value.isBlank()) return value;
|
||||
|
||||
+1
@@ -1,2 +1,3 @@
|
||||
com.baiyue.common.security.HmacJwtDecoderAutoConfiguration
|
||||
com.baiyue.common.security.JwksJwtDecoderAutoConfiguration
|
||||
com.baiyue.common.security.BaiyueSecurityAutoConfiguration
|
||||
|
||||
@@ -6,5 +6,7 @@ spring:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
baiyue:
|
||||
security:
|
||||
access-token-ttl: ${ACCESS_TOKEN_TTL:2h}
|
||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||
current-kid: ${JWT_CURRENT_KID:}
|
||||
access-token-ttl: ${ACCESS_TOKEN_TTL:15m}
|
||||
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
|
||||
|
||||
@@ -2401,7 +2401,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键',
|
||||
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
|
||||
inbound_code VARCHAR(40) NOT NULL COMMENT '客户内唯一入仓码',
|
||||
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜',
|
||||
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、PREPARING草稿预入库、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜',
|
||||
order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照',
|
||||
measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回',
|
||||
fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废',
|
||||
@@ -2657,6 +2657,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
||||
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
|
||||
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
|
||||
label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '箱唛批次版本,从1递增',
|
||||
batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废',
|
||||
cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹',
|
||||
voided_at DATETIME(3) NULL COMMENT '批次作废时间',
|
||||
box_specification VARCHAR(255) NOT NULL COMMENT '订单箱规或货物明细描述快照',
|
||||
length_cm DECIMAL(12,3) NULL COMMENT '下单箱规长度cm快照',
|
||||
width_cm DECIMAL(12,3) NULL COMMENT '下单箱规宽度cm快照',
|
||||
@@ -2664,7 +2668,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code),
|
||||
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index),
|
||||
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index),
|
||||
KEY idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version),
|
||||
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
|
||||
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
|
||||
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
|
||||
@@ -14819,6 +14824,9 @@ CALL assert_finance_permissions();
|
||||
DROP PROCEDURE IF EXISTS assert_finance_permissions;
|
||||
|
||||
USE baiyue_firstleg;
|
||||
-- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。
|
||||
ALTER TABLE fl_inbound_outbox
|
||||
MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀';
|
||||
CALL baiyue_firstleg.assert_unified_final_schema();
|
||||
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
|
||||
SELECT 'PASS' AS migration_status,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<artifactId>firstleg-service</artifactId>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-core</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.firstleg.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 = "loadingCompletedEventBridgeClient")
|
||||
public interface EventBridgeClient {
|
||||
@PostMapping("/internal/event-bridge/events")
|
||||
ApiResponse<Acknowledgement> receive(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody EventEnvelope envelope);
|
||||
|
||||
record Acknowledgement(String eventId, String result, String receivedAt) { }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
|
||||
|
||||
@FeignClient(name = "order-service", contextId = "firstlegOrderCargoClient",
|
||||
url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes")
|
||||
@@ -24,6 +25,12 @@ public interface OrderCargoClient {
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long orderId);
|
||||
|
||||
@GetMapping("/{tenantId}/{orderId}/inbound-snapshot")
|
||||
ApiResponse<OrderApprovedInboundCommand> inboundSnapshot(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long orderId);
|
||||
|
||||
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
|
||||
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
|
||||
|
||||
+40
@@ -62,6 +62,33 @@ public class InternalInboundOrderController {
|
||||
return ApiResponse.success(service.createFromApprovedOrder(command));
|
||||
}
|
||||
|
||||
@PostMapping("/order-preparing")
|
||||
public ApiResponse<Long> prepareFromDraft(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody OrderApprovedInboundCommand command) {
|
||||
requireToken(token);
|
||||
requireSnapshot(command);
|
||||
return ApiResponse.success(service.prepareFromDraft(command));
|
||||
}
|
||||
|
||||
@PostMapping("/order-preparing/batch")
|
||||
public ApiResponse<List<InboundOrderReference.Item>> prepareFromDraftBatch(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody List<OrderApprovedInboundCommand> commands) {
|
||||
requireToken(token);
|
||||
if (commands == null || commands.isEmpty() || commands.size() > 200) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "预入库订单批次必须为1至200条");
|
||||
}
|
||||
commands.forEach(this::requireSnapshot);
|
||||
long tenantId = commands.get(0).tenantId();
|
||||
if (commands.stream().anyMatch(command -> command.tenantId() != tenantId)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "预入库订单批次必须属于同一客户");
|
||||
}
|
||||
commands.forEach(service::prepareFromDraft);
|
||||
return ApiResponse.success(service.findReferences(tenantId,
|
||||
commands.stream().map(OrderApprovedInboundCommand::orderId).distinct().toList()));
|
||||
}
|
||||
|
||||
@PostMapping("/order-references")
|
||||
public ApiResponse<List<InboundOrderReference.Item>> references(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@@ -101,4 +128,17 @@ public class InternalInboundOrderController {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSnapshot(OrderApprovedInboundCommand command) {
|
||||
if (command == null || command.tenantId() <= 0 || command.orderId() <= 0
|
||||
|| command.orderNo() == null || command.orderNo().isBlank()
|
||||
|| command.inboundCode() == null || command.inboundCode().isBlank()
|
||||
|| command.inboundCode().length() > 40
|
||||
|| command.customerName() == null || command.customerName().isBlank()
|
||||
|| command.warehouseId() == null || command.warehouseId() <= 0
|
||||
|| command.warehouseName() == null || command.warehouseName().isBlank()
|
||||
|| command.businessType() == null || command.businessType().isBlank()) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "草稿预入库快照不完整");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.InboundCartonLabelPrintService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.constraints.Min;
|
||||
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;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
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.RequestBody;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 客户端下载本客户入库单箱唛。 */
|
||||
@Tag(name = "客户端入库箱唛")
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/first-leg")
|
||||
public class MerchantInboundCartonLabelController {
|
||||
@Resource
|
||||
private InboundCartonLabelPrintService cartonLabelPrintService;
|
||||
|
||||
@Operation(summary = "生成入仓箱唛 PDF")
|
||||
@PostMapping("/inbounds/{inboundId}/carton-label-pdf")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ResponseEntity<byte[]> print(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Min(1) long inboundId,
|
||||
@RequestParam(required = false) List<@Min(1) Integer> cartonIndex,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
return cartonLabelPrintService.generate(
|
||||
FirstlegScopeGuard.merchant(user), inboundId, cartonIndex, requestId);
|
||||
}
|
||||
|
||||
@Operation(summary = "按订单生成入仓箱唛 PDF")
|
||||
@PostMapping("/orders/{orderId}/carton-label-pdf")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ResponseEntity<byte[]> printByOrder(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Min(1) long orderId,
|
||||
@RequestParam(required = false) List<@Min(1) Integer> cartonIndex,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
return cartonLabelPrintService.generateByOrder(
|
||||
FirstlegScopeGuard.merchant(user), orderId, cartonIndex, requestId);
|
||||
}
|
||||
|
||||
@Operation(summary = "批量按订单生成箱唛 PDF")
|
||||
@PostMapping("/orders/carton-label-pdf")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ResponseEntity<byte[]> printByOrders(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestBody List<Long> orderIds,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
return cartonLabelPrintService.generateByOrders(
|
||||
FirstlegScopeGuard.merchant(user), orderIds, requestId);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import java.util.Set;
|
||||
public final class InboundProcessStatus {
|
||||
/** 等待录入或接收测量数据。 */
|
||||
public static final String WAIT_MEASURE = "WAIT_MEASURE";
|
||||
/** 草稿预入库:允许生成箱唛,但尚未进入正式测量流程。 */
|
||||
public static final String PREPARING = "PREPARING";
|
||||
/** 已存在测量草稿、重量 OCR 或人工修正,尚未提交审核。 */
|
||||
public static final String MEASURING = "MEASURING";
|
||||
/** 测量已提交,等待平台审核。 */
|
||||
@@ -30,7 +32,7 @@ public final class InboundProcessStatus {
|
||||
public static final String VOIDED = "VOIDED";
|
||||
|
||||
public static final Set<String> VALUES = Set.of(
|
||||
WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED,
|
||||
PREPARING, WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED,
|
||||
WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, WAIT_SPLIT,
|
||||
LOADING, LOADED, DELIVERED, VOIDED
|
||||
);
|
||||
|
||||
@@ -17,4 +17,5 @@ public final class InboundOrderReference {
|
||||
|
||||
public record Item(Long orderId, Long inboundId, String inboundCode, String processStatus) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ public class InboundCartonLabelEntity {
|
||||
private String boxCode;
|
||||
private Integer totalCartonCount;
|
||||
private Integer cartonIndex;
|
||||
private Integer labelVersion;
|
||||
private String batchStatus;
|
||||
private String cargoFingerprint;
|
||||
private LocalDateTime voidedAt;
|
||||
private String boxSpecification;
|
||||
private BigDecimal lengthCm;
|
||||
private BigDecimal widthCm;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */
|
||||
@Mapper
|
||||
public interface LoadingCompletedEventMapper {
|
||||
@Select("""
|
||||
SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version,
|
||||
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
|
||||
i.account_id, i.inbound_id AS source_inbound_id,
|
||||
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.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,
|
||||
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
|
||||
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_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'
|
||||
AND i.package_count > 0
|
||||
ORDER BY i.account_id, i.inbound_id, i.inbound_detail_id, i.id
|
||||
""")
|
||||
List<Row> rows(@Param("planId") long planId);
|
||||
|
||||
@Getter @Setter
|
||||
class Row {
|
||||
private Long loadingPlanId; private String planNo; private Long containerBookingId;
|
||||
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 String sourceOrderNo; private Long rootOrderId; private String inboundCode;
|
||||
private Long destinationWarehouseId; private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName; private Long 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 fbaTrackingNo; private String shipmentNo; private String shippingMark;
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,10 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
|
||||
label.box_code AS boxCode
|
||||
FROM fl_loading_pick_item pick_item
|
||||
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
|
||||
JOIN fl_inbound_carton_label label ON label.tenant_id = plan_item.account_id
|
||||
JOIN fl_inbound_carton_label label ON label.tenant_id = plan_item.account_id
|
||||
AND label.inbound_id = plan_item.inbound_id
|
||||
AND label.inbound_detail_id = plan_item.inbound_detail_id
|
||||
AND label.batch_status = 'ACTIVE'
|
||||
WHERE pick_item.pick_task_id = #{taskId} AND plan_item.deleted = 0
|
||||
ORDER BY pick_item.id, label.carton_index
|
||||
""")
|
||||
|
||||
+53
-1
@@ -24,19 +24,63 @@ public class InboundCartonLabelRepository {
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
public List<InboundCartonLabelEntity> findByInbound(long tenantId, long inboundId) {
|
||||
public InboundOrderEntity findByOrder(long tenantId, long orderId) {
|
||||
return inbounds.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
.eq(InboundOrderEntity::getOrderId, orderId)
|
||||
.eq(InboundOrderEntity::getDeleted, false)
|
||||
.orderByDesc(InboundOrderEntity::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
public List<InboundCartonLabelEntity> findActiveByInbound(long tenantId, long inboundId) {
|
||||
return labels.selectList(new LambdaQueryWrapper<InboundCartonLabelEntity>()
|
||||
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
|
||||
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
|
||||
.eq(InboundCartonLabelEntity::getBatchStatus, "ACTIVE")
|
||||
.orderByAsc(InboundCartonLabelEntity::getCartonIndex));
|
||||
}
|
||||
|
||||
/** Compatibility for older callers; new code must use the ACTIVE-only query. */
|
||||
public List<InboundCartonLabelEntity> findByInbound(long tenantId, long inboundId) {
|
||||
return findActiveByInbound(tenantId, inboundId);
|
||||
}
|
||||
|
||||
public InboundCartonLabelEntity findByBoxCode(long tenantId, String boxCode) {
|
||||
return labels.selectOne(new LambdaQueryWrapper<InboundCartonLabelEntity>()
|
||||
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
|
||||
.eq(InboundCartonLabelEntity::getBoxCode, boxCode));
|
||||
}
|
||||
|
||||
public int nextVersion(long tenantId, long inboundId) {
|
||||
InboundCartonLabelEntity latest = labels.selectOne(new LambdaQueryWrapper<InboundCartonLabelEntity>()
|
||||
.select(InboundCartonLabelEntity::getLabelVersion)
|
||||
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
|
||||
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
|
||||
.orderByDesc(InboundCartonLabelEntity::getLabelVersion)
|
||||
.last("LIMIT 1"));
|
||||
return latest == null || latest.getLabelVersion() == null ? 1 : latest.getLabelVersion() + 1;
|
||||
}
|
||||
|
||||
public void voidActive(long tenantId, long inboundId, java.time.LocalDateTime at) {
|
||||
labels.update(null, new LambdaUpdateWrapper<InboundCartonLabelEntity>()
|
||||
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
|
||||
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
|
||||
.eq(InboundCartonLabelEntity::getBatchStatus, "ACTIVE")
|
||||
.set(InboundCartonLabelEntity::getBatchStatus, "VOID")
|
||||
.set(InboundCartonLabelEntity::getVoidedAt, at));
|
||||
}
|
||||
|
||||
public void initializeLegacyFingerprint(long tenantId, long inboundId, int version, String fingerprint) {
|
||||
labels.update(null, new LambdaUpdateWrapper<InboundCartonLabelEntity>()
|
||||
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
|
||||
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
|
||||
.eq(InboundCartonLabelEntity::getLabelVersion, version)
|
||||
.eq(InboundCartonLabelEntity::getBatchStatus, "ACTIVE")
|
||||
.isNull(InboundCartonLabelEntity::getCargoFingerprint)
|
||||
.set(InboundCartonLabelEntity::getCargoFingerprint, fingerprint));
|
||||
}
|
||||
|
||||
public boolean initializeOriginalPackageCount(long tenantId, long inboundId, int packageCount) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
@@ -46,6 +90,14 @@ public class InboundCartonLabelRepository {
|
||||
.setSql("version = version + 1")) == 1;
|
||||
}
|
||||
|
||||
public void updatePackageCount(long tenantId, long inboundId, int packageCount) {
|
||||
inbounds.update(null, new LambdaUpdateWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
.eq(InboundOrderEntity::getId, inboundId)
|
||||
.set(InboundOrderEntity::getTotalPackageCount, packageCount)
|
||||
.setSql("version = version + 1"));
|
||||
}
|
||||
|
||||
public void insert(InboundCartonLabelEntity label) {
|
||||
labels.insert(label);
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.firstleg.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.firstleg.entity.InboundOutboxEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class LoadingCompletedOutboxRepository {
|
||||
@Resource private InboundOutboxMapper mapper;
|
||||
|
||||
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now) {
|
||||
return mapper.selectList(new LambdaQueryWrapper<InboundOutboxEntity>()
|
||||
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
|
||||
.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
|
||||
.and(v -> v.isNull(InboundOutboxEntity::getNextRetryAt).or().le(InboundOutboxEntity::getNextRetryAt, now))
|
||||
.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")
|
||||
.set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker)
|
||||
.set(InboundOutboxEntity::getLockedAt, now)) == 1; }
|
||||
public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper<InboundOutboxEntity>()
|
||||
.eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
|
||||
.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)
|
||||
.set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null)
|
||||
.set(InboundOutboxEntity::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(InboundOutboxEntity::getPublishStatus, "PENDING")
|
||||
.set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null)
|
||||
.set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); }
|
||||
private LambdaUpdateWrapper<InboundOutboxEntity> base(long id, String status) { return new LambdaUpdateWrapper<InboundOutboxEntity>()
|
||||
.eq(InboundOutboxEntity::getId,id).eq(InboundOutboxEntity::getEventType,EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
|
||||
.eq(InboundOutboxEntity::getPublishStatus,status); }
|
||||
private LambdaUpdateWrapper<InboundOutboxEntity> 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; }
|
||||
}
|
||||
+49
-1
@@ -9,6 +9,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -17,6 +18,7 @@ import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
|
||||
/** Allocates stable carton codes and forwards immutable label facts to Print. */
|
||||
@Service
|
||||
@@ -50,7 +52,53 @@ public class InboundCartonLabelPrintService {
|
||||
batch.labels().stream().map(this::toPrintLabel).toList());
|
||||
try {
|
||||
return requirePdf(printClient.generateInboundMarks(internalToken, requestId, request));
|
||||
} catch (FeignException exception) {
|
||||
} catch (FeignException | NoFallbackAvailableException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> generateByOrder(long tenantId, long orderId,
|
||||
List<Integer> cartonIndexes, String requestId) {
|
||||
return generate(tenantId, labelService.requireInboundIdByOrder(tenantId, orderId),
|
||||
cartonIndexes, requestId);
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> generateByOrders(long tenantId, List<Long> orderIds, String requestId) {
|
||||
if (orderIds == null || orderIds.isEmpty() || orderIds.size() > 200
|
||||
|| orderIds.stream().anyMatch(id -> id == null || id <= 0)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "订单ID数组必须包含1至200个有效值");
|
||||
}
|
||||
List<InboundCartonLabels.Label> labels = new ArrayList<>();
|
||||
List<String> failures = new ArrayList<>();
|
||||
for (Long orderId : orderIds.stream().distinct().toList()) {
|
||||
try {
|
||||
long inboundId = labelService.requireInboundIdByOrder(tenantId, orderId);
|
||||
labels.addAll(labelService.list(tenantId, inboundId, null).labels());
|
||||
} catch (BizException exception) {
|
||||
failures.add(orderId + ":" + exception.getResultCode().code());
|
||||
}
|
||||
}
|
||||
if (labels.isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
failures.isEmpty() ? "没有可生成箱唛的订单" : "所选订单均无法生成箱唛:" + String.join(",", failures));
|
||||
}
|
||||
ResponseEntity<byte[]> response = generatePdf("carton-labels-" + labels.size() + ".pdf", labels, requestId);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.putAll(response.getHeaders());
|
||||
headers.set("X-Carton-Label-Order-Count", String.valueOf(orderIds.stream().distinct().count()));
|
||||
headers.set("X-Carton-Label-Failure-Count", String.valueOf(failures.size()));
|
||||
if (!failures.isEmpty()) headers.set("X-Carton-Label-Failures", String.join(",", failures));
|
||||
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> generatePdf(String fileName, List<InboundCartonLabels.Label> labels,
|
||||
String requestId) {
|
||||
PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest(
|
||||
"INBOUND_MARK_V1", new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM), fileName,
|
||||
labels.stream().map(this::toPrintLabel).toList());
|
||||
try {
|
||||
return requirePdf(printClient.generateInboundMarks(internalToken, requestId, request));
|
||||
} catch (FeignException | NoFallbackAvailableException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,16 +22,20 @@ import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
/** 首次生成后不可变的箱号分配;重打和测量解析只读取该映射。 */
|
||||
@Service
|
||||
public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
private static final int MAX_LABELS = 2000;
|
||||
private static final int MAX_QR_CODE_POINTS = 34;
|
||||
private static final Set<String> ALLOCATABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURING");
|
||||
private static final Set<String> ALLOCATABLE_STATUSES = Set.of("PREPARING", "WAIT_MEASURE", "MEASURING");
|
||||
|
||||
@Resource private InboundCartonLabelRepository repository;
|
||||
@Resource private OrderCargoClient orderCargoClient;
|
||||
@Resource private InboundOrderCreationService inboundOrderCreationService;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
@Transactional
|
||||
@@ -40,23 +44,32 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId);
|
||||
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
|
||||
List<InboundCartonLabelEntity> existing = new ArrayList<>(repository.findByInbound(tenantId, inboundId));
|
||||
if (!existing.isEmpty()) {
|
||||
List<InboundCartonLabelEntity> consistent = requireConsistent(existing);
|
||||
freezeOriginalPackageCount(inbound, consistent.get(0).getTotalCartonCount());
|
||||
return batch(inbound, consistent);
|
||||
}
|
||||
if (!ALLOCATABLE_STATUSES.contains(inbound.getProcessStatus())) {
|
||||
List<InboundCartonLabelEntity> existing = new ArrayList<>(activeLabels(tenantId, inboundId));
|
||||
List<InboundCartonLabelEntity> consistent = existing.isEmpty() ? List.of() : requireConsistent(existing);
|
||||
if (existing.isEmpty() && !ALLOCATABLE_STATUSES.contains(inbound.getProcessStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许首次生成箱号");
|
||||
}
|
||||
|
||||
List<OrderCargoClient.CargoLabelBinding> cargoes = loadCargoes(inbound);
|
||||
String fingerprint = fingerprint(cargoes);
|
||||
if (!existing.isEmpty()) {
|
||||
String existingFingerprint = consistent.get(0).getCargoFingerprint();
|
||||
if (existingFingerprint == null) {
|
||||
repository.initializeLegacyFingerprint(tenantId, inboundId, version(consistent.get(0)), fingerprint);
|
||||
consistent.forEach(label -> label.setCargoFingerprint(fingerprint));
|
||||
return batch(inbound, consistent);
|
||||
}
|
||||
if (existingFingerprint.equals(fingerprint)) return batch(inbound, consistent);
|
||||
repository.voidActive(tenantId, inboundId, LocalDateTime.now());
|
||||
existing.clear();
|
||||
}
|
||||
int total = cargoes.stream().mapToInt(this::requirePackageCount).sum();
|
||||
if (total < 1 || total > MAX_LABELS) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "唛头箱数必须为1至2000箱");
|
||||
}
|
||||
freezeOriginalPackageCount(inbound, total);
|
||||
requirePrintableInboundCode(inbound.getInboundCode(), total);
|
||||
repository.updatePackageCount(tenantId, inboundId, total);
|
||||
inbound.setTotalPackageCount(total);
|
||||
int labelVersion = repository.nextVersion(tenantId, inboundId);
|
||||
requirePrintableInboundCode(inbound.getInboundCode(), total, labelVersion);
|
||||
|
||||
int cartonIndex = 0;
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -64,7 +77,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
int detailCount = requirePackageCount(cargo);
|
||||
for (int offset = 0; offset < detailCount; offset++) {
|
||||
cartonIndex++;
|
||||
InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, now);
|
||||
InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, labelVersion, fingerprint, now);
|
||||
repository.insert(label);
|
||||
existing.add(label);
|
||||
}
|
||||
@@ -72,6 +85,31 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
return batch(inbound, List.copyOf(existing));
|
||||
}
|
||||
|
||||
public long requireInboundIdByOrder(long tenantId, long orderId) {
|
||||
requireIds(tenantId, orderId);
|
||||
InboundOrderEntity inbound = repository.findByOrder(tenantId, orderId);
|
||||
if (inbound == null) inbound = prepareMissingInbound(tenantId, orderId);
|
||||
return inbound.getId();
|
||||
}
|
||||
|
||||
private InboundOrderEntity prepareMissingInbound(long tenantId, long orderId) {
|
||||
try {
|
||||
ApiResponse<com.baiyue.firstleg.dto.OrderApprovedInboundCommand> response =
|
||||
orderCargoClient.inboundSnapshot(internalToken, tenantId, orderId);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "订单不存在或无法生成预入库单");
|
||||
}
|
||||
inboundOrderCreationService.prepareFromDraft(response.data());
|
||||
InboundOrderEntity prepared = repository.findByOrder(tenantId, orderId);
|
||||
if (prepared == null) {
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR, "预入库单补建后未能读取");
|
||||
}
|
||||
return prepared;
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法补建预入库单");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundCartonLabels.Batch list(long tenantId, long inboundId, List<Integer> cartonIndexes) {
|
||||
InboundCartonLabels.Batch batch = getOrCreate(tenantId, inboundId);
|
||||
@@ -109,6 +147,9 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
}
|
||||
InboundCartonLabelEntity label = repository.findByBoxCode(tenantId, boxCode.trim());
|
||||
if (label == null) throw new BizException(ResultCode.NOT_FOUND, "箱号不存在");
|
||||
if ("VOID".equals(label.getBatchStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "箱唛版本已作废,请重新打印");
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
@@ -141,33 +182,20 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
return cargo.packageCount();
|
||||
}
|
||||
|
||||
private void requirePrintableInboundCode(String inboundCode, int total) {
|
||||
private void requirePrintableInboundCode(String inboundCode, int total, int version) {
|
||||
if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码不能生成可打印二维码");
|
||||
}
|
||||
String longest = inboundCode + "#" + total + "-" + total;
|
||||
String longest = inboundCode + "#V" + version + "#" + total + "-" + total;
|
||||
if (longest.codePointCount(0, longest.length()) > MAX_QR_CODE_POINTS) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码和箱号组合超过二维码长度限制");
|
||||
}
|
||||
}
|
||||
|
||||
private void freezeOriginalPackageCount(InboundOrderEntity inbound, int total) {
|
||||
Integer original = inbound.getTotalPackageCount();
|
||||
if (original != null && original != total) {
|
||||
throw new BizException(ResultCode.CONFLICT, "订单总箱数与已冻结箱号数量不一致");
|
||||
}
|
||||
if (original == null) {
|
||||
if (!repository.initializeOriginalPackageCount(inbound.getTenantId(), inbound.getId(), total)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "订单总箱数已变化,请刷新后重试");
|
||||
}
|
||||
inbound.setTotalPackageCount(total);
|
||||
inbound.setVersion(inbound.getVersion() == null ? 1 : inbound.getVersion() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private InboundCartonLabelEntity label(InboundOrderEntity inbound,
|
||||
OrderCargoClient.CargoLabelBinding cargo,
|
||||
int total, int index, LocalDateTime now) {
|
||||
int total, int index, int labelVersion, String fingerprint,
|
||||
LocalDateTime now) {
|
||||
InboundCartonLabelEntity label = new InboundCartonLabelEntity();
|
||||
label.setId(IdWorker.getId());
|
||||
label.setTenantId(inbound.getTenantId());
|
||||
@@ -178,9 +206,12 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
label.setCargoName(cargo.cargoName());
|
||||
label.setDetailPackageCount(cargo.packageCount());
|
||||
label.setInboundCode(inbound.getInboundCode());
|
||||
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
|
||||
label.setBoxCode(inbound.getInboundCode() + "#V" + labelVersion + "#" + total + "-" + index);
|
||||
label.setTotalCartonCount(total);
|
||||
label.setCartonIndex(index);
|
||||
label.setLabelVersion(labelVersion);
|
||||
label.setBatchStatus("ACTIVE");
|
||||
label.setCargoFingerprint(fingerprint);
|
||||
label.setLengthCm(cargo.lengthCm());
|
||||
label.setWidthCm(cargo.widthCm());
|
||||
label.setHeightCm(cargo.heightCm());
|
||||
@@ -203,14 +234,45 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
|
||||
private List<InboundCartonLabelEntity> requireConsistent(List<InboundCartonLabelEntity> labels) {
|
||||
int total = labels.get(0).getTotalCartonCount();
|
||||
int version = version(labels.get(0));
|
||||
if (total != labels.size()) throw corrupt();
|
||||
for (int index = 0; index < labels.size(); index++) {
|
||||
InboundCartonLabelEntity label = labels.get(index);
|
||||
if (label.getTotalCartonCount() != total || label.getCartonIndex() != index + 1) throw corrupt();
|
||||
if (label.getTotalCartonCount() != total || label.getCartonIndex() != index + 1
|
||||
|| version(label) != version || "VOID".equals(label.getBatchStatus())) throw corrupt();
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
private int version(InboundCartonLabelEntity label) {
|
||||
return label.getLabelVersion() == null ? 1 : label.getLabelVersion();
|
||||
}
|
||||
|
||||
private List<InboundCartonLabelEntity> activeLabels(long tenantId, long inboundId) {
|
||||
List<InboundCartonLabelEntity> result = repository.findActiveByInbound(tenantId, inboundId);
|
||||
if (result != null && !result.isEmpty()) return result;
|
||||
return repository.findByInbound(tenantId, inboundId).stream()
|
||||
.filter(label -> label.getBatchStatus() == null || "ACTIVE".equals(label.getBatchStatus()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String fingerprint(List<OrderCargoClient.CargoLabelBinding> cargoes) {
|
||||
String source = cargoes.stream().map(cargo -> String.join("|", String.valueOf(cargo.itemNo()),
|
||||
cargo.cargoName() == null ? "" : cargo.cargoName().trim(), String.valueOf(cargo.packageCount()),
|
||||
decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm())))
|
||||
.collect(java.util.stream.Collectors.joining("\n"));
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder value = new StringBuilder(64);
|
||||
for (byte item : digest) value.append(String.format("%02x", item));
|
||||
return value.toString();
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR, "无法生成箱唛货物指纹");
|
||||
}
|
||||
}
|
||||
|
||||
private String decimalOrEmpty(BigDecimal value) { return value == null ? "" : decimal(value); }
|
||||
|
||||
private BizException corrupt() {
|
||||
return new BizException(ResultCode.INTERNAL_ERROR, "入库单箱号映射不完整,请联系管理员");
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ public class InboundOrderCreationService {
|
||||
long inboundId = requireMatchingInboundCode(existing, command.inboundCode());
|
||||
FulfillmentTaskEntity task = fulfillmentTasks.provision(command, LocalDateTime.now());
|
||||
linkTask(existing, task.getId());
|
||||
upgradePreparing(existing, command);
|
||||
return inboundId;
|
||||
}
|
||||
|
||||
@@ -107,6 +108,146 @@ public class InboundOrderCreationService {
|
||||
return inbound.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the real first-leg aggregate used by draft carton labels. It is
|
||||
* deliberately not a fulfillment task yet; approval upgrades the same row
|
||||
* in-place so the inbound id printed on labels never changes.
|
||||
*/
|
||||
@Transactional
|
||||
public long prepareFromDraft(OrderApprovedInboundCommand command) {
|
||||
InboundOrderEntity existing = findByOrder(command.tenantId(), command.orderId(), false);
|
||||
if (existing != null) {
|
||||
long inboundId = requireMatchingInboundCode(existing, command.inboundCode());
|
||||
refreshPreparing(existing, command);
|
||||
return inboundId;
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
InboundOrderEntity inbound = newInbound(command, now, null, "PREPARING", "DRAFT");
|
||||
try {
|
||||
orders.insert(inbound);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
InboundOrderEntity winner = findByOrder(command.tenantId(), command.orderId(), true);
|
||||
if (winner == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "入仓码已被其他订单使用");
|
||||
}
|
||||
return requireMatchingInboundCode(winner, command.inboundCode());
|
||||
}
|
||||
addHistory(inbound.getId(), command.tenantId(), null, "PREPARING", "ORDER_DRAFT_PREPARED", now);
|
||||
return inbound.getId();
|
||||
}
|
||||
|
||||
private void refreshPreparing(InboundOrderEntity existing, OrderApprovedInboundCommand command) {
|
||||
if (!"PREPARING".equals(existing.getProcessStatus())) return;
|
||||
orders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, existing.getTenantId())
|
||||
.eq(InboundOrderEntity::getId, existing.getId())
|
||||
.eq(InboundOrderEntity::getProcessStatus, "PREPARING")
|
||||
.set(InboundOrderEntity::getOrderNo, command.orderNo())
|
||||
.set(InboundOrderEntity::getCustomerId, command.customerId())
|
||||
.set(InboundOrderEntity::getCustomerName, command.customerName())
|
||||
.set(InboundOrderEntity::getWarehouseId, command.warehouseId())
|
||||
.set(InboundOrderEntity::getWarehouseName, command.warehouseName())
|
||||
.set(InboundOrderEntity::getDeliveryWarehouseId, command.deliveryWarehouseId())
|
||||
.set(InboundOrderEntity::getDeliveryWarehouseName, command.deliveryWarehouseName())
|
||||
.set(InboundOrderEntity::getExpectedArrivalAt, command.expectedArrivalAt())
|
||||
.set(InboundOrderEntity::getBusinessType, command.businessType())
|
||||
.set(InboundOrderEntity::getChannelCode, command.channelCode())
|
||||
.set(InboundOrderEntity::getChannelName, command.channelName())
|
||||
.set(InboundOrderEntity::getDestinationType, command.destinationType())
|
||||
.set(InboundOrderEntity::getDestinationWarehouseId, command.destinationWarehouseId())
|
||||
.set(InboundOrderEntity::getDestinationCode, command.destinationCode())
|
||||
.set(InboundOrderEntity::getDestinationName, command.destinationName())
|
||||
.set(InboundOrderEntity::getPublicRemark, command.publicRemark())
|
||||
.set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount())
|
||||
.set(InboundOrderEntity::getUpdatedAt, LocalDateTime.now())
|
||||
.setSql("version = version + 1"));
|
||||
}
|
||||
|
||||
private void upgradePreparing(InboundOrderEntity existing, OrderApprovedInboundCommand command) {
|
||||
if (!"PREPARING".equals(existing.getProcessStatus())) return;
|
||||
var update = new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, existing.getTenantId())
|
||||
.eq(InboundOrderEntity::getId, existing.getId())
|
||||
.eq(InboundOrderEntity::getProcessStatus, "PREPARING")
|
||||
.set(InboundOrderEntity::getProcessStatus, "WAIT_MEASURE")
|
||||
.set(InboundOrderEntity::getOrderStatusSnapshot, "APPROVED")
|
||||
.set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount())
|
||||
.set(InboundOrderEntity::getUpdatedAt, LocalDateTime.now())
|
||||
.setSql("version = version + 1");
|
||||
if (orders.update(null, update) != 1) {
|
||||
InboundOrderEntity current = findByOrder(existing.getTenantId(), existing.getOrderId(), true);
|
||||
if (current == null || !"WAIT_MEASURE".equals(current.getProcessStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "预入库单审核升级发生并发冲突");
|
||||
}
|
||||
return;
|
||||
}
|
||||
addHistory(existing.getId(), existing.getTenantId(), "PREPARING", "WAIT_MEASURE",
|
||||
"ORDER_APPROVED", LocalDateTime.now());
|
||||
}
|
||||
|
||||
private InboundOrderEntity newInbound(OrderApprovedInboundCommand command, LocalDateTime now,
|
||||
Long taskId, String processStatus, String orderStatus) {
|
||||
InboundOrderEntity inbound = new InboundOrderEntity();
|
||||
inbound.setId(IdWorker.getId());
|
||||
inbound.setTenantId(command.tenantId());
|
||||
inbound.setTaskId(taskId);
|
||||
inbound.setOrderId(command.orderId());
|
||||
inbound.setOrderNo(command.orderNo());
|
||||
inbound.setInboundCode(command.inboundCode());
|
||||
inbound.setProcessStatus(processStatus);
|
||||
inbound.setOrderStatusSnapshot(orderStatus);
|
||||
inbound.setMeasureReviewStatus("NOT_SUBMITTED");
|
||||
inbound.setFeeStatus("UNPRICED");
|
||||
inbound.setFeeReviewStatus("NOT_SUBMITTED");
|
||||
inbound.setNotificationStatus("NOT_CREATED");
|
||||
inbound.setExpectedArrivalAt(command.expectedArrivalAt());
|
||||
inbound.setCustomerId(command.customerId());
|
||||
inbound.setCustomerName(command.customerName());
|
||||
inbound.setWarehouseId(command.warehouseId());
|
||||
inbound.setWarehouseName(command.warehouseName());
|
||||
inbound.setDeliveryWarehouseId(command.deliveryWarehouseId());
|
||||
inbound.setDeliveryWarehouseName(command.deliveryWarehouseName());
|
||||
inbound.setBusinessType(command.businessType());
|
||||
inbound.setChannelCode(command.channelCode());
|
||||
inbound.setChannelName(command.channelName());
|
||||
inbound.setCarrierId(command.carrierId());
|
||||
inbound.setCarrierName(command.carrierName());
|
||||
inbound.setTaxMode(command.taxMode());
|
||||
inbound.setPricingTaxMode(command.pricingTaxMode());
|
||||
inbound.setContainerMode(command.containerMode());
|
||||
inbound.setCustomsInspection(command.customsInspection());
|
||||
inbound.setDestinationType(command.destinationType());
|
||||
inbound.setDestinationWarehouseId(command.destinationWarehouseId());
|
||||
inbound.setDestinationCode(command.destinationCode());
|
||||
inbound.setDestinationName(command.destinationName());
|
||||
inbound.setExpressCompanyName(command.expressCompanyName());
|
||||
inbound.setExpressTrackingNo(command.expressTrackingNo());
|
||||
inbound.setPublicRemark(command.publicRemark());
|
||||
inbound.setCreatedBy(0L);
|
||||
inbound.setUpdatedBy(0L);
|
||||
inbound.setCreatedAt(now);
|
||||
inbound.setUpdatedAt(now);
|
||||
inbound.setDeleted(false);
|
||||
inbound.setVersion(0);
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private void addHistory(long inboundId, long tenantId, String from, String to,
|
||||
String action, LocalDateTime now) {
|
||||
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
|
||||
history.setId(IdWorker.getId());
|
||||
history.setTenantId(tenantId);
|
||||
history.setInboundId(inboundId);
|
||||
history.setFromStatus(from);
|
||||
history.setToStatus(to);
|
||||
history.setAction(action);
|
||||
history.setOperatorId(0L);
|
||||
history.setOperatorName("订单服务");
|
||||
history.setOccurredAt(now);
|
||||
histories.insert(history);
|
||||
}
|
||||
|
||||
private InboundOrderEntity findByOrder(long tenantId, long orderId, boolean currentRead) {
|
||||
var query = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventEnvelopeValidator;
|
||||
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 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 LoadingCompletedOutboxRelay {
|
||||
@Resource private LoadingCompletedOutboxRepository repository;
|
||||
@Resource private EventBridgeClient client;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
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;
|
||||
InboundOutboxEntity 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(30),exception.getMessage());
|
||||
log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.baiyue.firstleg.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.FirstLegLoadingPlanCompletedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
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.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;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 在装柜完成事务内按客户写跨区业务 Outbox。 */
|
||||
@Service
|
||||
public class LoadingCompletedOutboxService {
|
||||
private static final long HANDOVER_VERSION = 1L;
|
||||
@Resource private LoadingCompletedEventMapper snapshotMapper;
|
||||
@Resource private InboundOutboxMapper outboxMapper;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
public void publish(long planId) {
|
||||
List<Row> rows = snapshotMapper.rows(planId);
|
||||
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
|
||||
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)));
|
||||
}
|
||||
|
||||
private EventEnvelope envelope(long accountId, List<Row> rows) {
|
||||
Row first = rows.get(0);
|
||||
Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList()));
|
||||
List<FirstLegLoadingPlanCompletedV1.Order> orders = byInbound.values().stream()
|
||||
.map(this::order).toList();
|
||||
var payload = new FirstLegLoadingPlanCompletedV1(String.valueOf(first.getLoadingPlanId()),
|
||||
first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(),
|
||||
string(first.getWarehouseId()), first.getWarehouseName(), orders);
|
||||
JsonNode payloadNode = objectMapper.valueToTree(payload);
|
||||
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
|
||||
String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION;
|
||||
String traceId = EventIdentifiers.newUuid();
|
||||
return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1,
|
||||
Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(accountId), "LOADING_PLAN",
|
||||
String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION,
|
||||
traceId, eventKey, null, payloadNode, hash).requireValid();
|
||||
}
|
||||
|
||||
private FirstLegLoadingPlanCompletedV1.Order order(List<Row> values) {
|
||||
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
|
||||
Row first = sorted.get(0);
|
||||
List<FirstLegLoadingPlanCompletedV1.Item> items = sorted.stream().map(row ->
|
||||
new FirstLegLoadingPlanCompletedV1.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(),
|
||||
row.getProductNameZh(), row.getProductNameEn(), row.getPackageCount(), row.getProductQuantity(),
|
||||
row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(),
|
||||
row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(),
|
||||
row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark()))
|
||||
.toList();
|
||||
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);
|
||||
}
|
||||
|
||||
private void insert(EventEnvelope envelope) {
|
||||
InboundOutboxEntity value = new InboundOutboxEntity();
|
||||
value.setId(IdWorker.getId()); value.setTenantId(Long.valueOf(envelope.tenantId()));
|
||||
value.setAggregateType(envelope.aggregateType()); value.setAggregateId(Long.valueOf(envelope.aggregateId()));
|
||||
value.setEventType(envelope.eventType()); value.setEventKey(envelope.correlationId());
|
||||
value.setEventVersion(envelope.eventVersion()); value.setAggregateSequence((int) envelope.aggregateVersion());
|
||||
value.setPayloadHash(envelope.payloadHash()); value.setPayloadJson(json(envelope));
|
||||
value.setPublishStatus("PENDING"); value.setRetryCount(0); value.setCreatedAt(LocalDateTime.now());
|
||||
try { outboxMapper.insert(value); } catch (DuplicateKeyException ignored) { }
|
||||
}
|
||||
|
||||
private String json(Object value) { try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException e) { throw new BizException(ResultCode.INTERNAL_ERROR, "装柜完成事件序列化失败"); } }
|
||||
private static String string(Long value) { return value == null ? null : String.valueOf(value); }
|
||||
private static int sumInt(List<Row> rows, java.util.function.Function<Row,Integer> getter) { return rows.stream().map(getter).filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); }
|
||||
private static Integer sumNullableInt(List<Row> rows, java.util.function.Function<Row,Integer> getter) { List<Integer> values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().mapToInt(Integer::intValue).sum(); }
|
||||
private static BigDecimal sum(List<Row> rows, java.util.function.Function<Row,BigDecimal> getter) { List<BigDecimal> values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); }
|
||||
}
|
||||
@@ -57,6 +57,8 @@ public class LoadingListService {
|
||||
@Resource
|
||||
private LoadingPickRepository pickRepository;
|
||||
@Resource
|
||||
private LoadingCompletedOutboxService loadingCompletedOutboxService;
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
@Resource
|
||||
private LoadingDocumentFileClient documentFiles;
|
||||
@@ -455,6 +457,7 @@ public class LoadingListService {
|
||||
json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED",
|
||||
"version", before.getVersion() + 1)), operatorId, trim(operatorName),
|
||||
"PDA已人工确认装柜完成");
|
||||
loadingCompletedOutboxService.publish(planId);
|
||||
}
|
||||
|
||||
private AggregateRow requirePlan(long planId) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
SET NAMES utf8mb4;
|
||||
USE baiyue_firstleg;
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase2_carton_label_add_column;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE phase2_carton_label_add_column(IN p_column VARCHAR(64), IN p_definition TEXT)
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'fl_inbound_carton_label' AND COLUMN_NAME = p_column) THEN
|
||||
SET @sql = CONCAT('ALTER TABLE fl_inbound_carton_label ADD COLUMN ', p_column, ' ', p_definition);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL phase2_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''箱唛批次版本,从1递增'' AFTER carton_index');
|
||||
CALL phase2_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version');
|
||||
CALL phase2_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status');
|
||||
CALL phase2_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint');
|
||||
DROP PROCEDURE phase2_carton_label_add_column;
|
||||
|
||||
UPDATE fl_inbound_carton_label
|
||||
SET label_version = 1,
|
||||
batch_status = 'ACTIVE'
|
||||
WHERE label_version IS NULL OR batch_status IS NULL;
|
||||
|
||||
UPDATE fl_inbound_carton_label
|
||||
SET box_code = CONCAT(inbound_code, '#V', label_version, '#', total_carton_count, '-', carton_index)
|
||||
WHERE box_code NOT LIKE CONCAT(inbound_code, '#V%#%');
|
||||
|
||||
SET @has_old_index := (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_carton_label'
|
||||
AND index_name = 'uk_carton_label_index');
|
||||
SET @drop_old_index := IF(@has_old_index > 0,
|
||||
'ALTER TABLE fl_inbound_carton_label DROP INDEX uk_carton_label_index', 'SELECT 1');
|
||||
PREPARE carton_label_stmt FROM @drop_old_index;
|
||||
EXECUTE carton_label_stmt;
|
||||
DEALLOCATE PREPARE carton_label_stmt;
|
||||
|
||||
SET @has_version_index := (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_carton_label'
|
||||
AND index_name = 'uk_carton_label_index');
|
||||
SET @add_version_index := IF(@has_version_index = 0,
|
||||
'ALTER TABLE fl_inbound_carton_label ADD UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index)', 'SELECT 1');
|
||||
PREPARE carton_label_index_stmt FROM @add_version_index;
|
||||
EXECUTE carton_label_index_stmt;
|
||||
DEALLOCATE PREPARE carton_label_index_stmt;
|
||||
|
||||
SET @has_active_index := (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_carton_label'
|
||||
AND index_name = 'idx_carton_label_active');
|
||||
SET @add_active_index := IF(@has_active_index = 0,
|
||||
'ALTER TABLE fl_inbound_carton_label ADD KEY idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version)', 'SELECT 1');
|
||||
PREPARE carton_label_active_stmt FROM @add_active_index;
|
||||
EXECUTE carton_label_active_stmt;
|
||||
DEALLOCATE PREPARE carton_label_active_stmt;
|
||||
@@ -15,6 +15,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
||||
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
|
||||
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
|
||||
label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '箱唛批次版本,从1递增',
|
||||
batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废',
|
||||
cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹',
|
||||
voided_at DATETIME(3) NULL COMMENT '批次作废时间',
|
||||
box_specification VARCHAR(255) NOT NULL COMMENT '订单箱规或货物明细描述快照',
|
||||
length_cm DECIMAL(12,3) NULL COMMENT '下单箱规长度cm快照',
|
||||
width_cm DECIMAL(12,3) NULL COMMENT '下单箱规宽度cm快照',
|
||||
@@ -22,7 +26,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code),
|
||||
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index),
|
||||
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index),
|
||||
KEY idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version),
|
||||
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
|
||||
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
|
||||
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
|
||||
|
||||
@@ -153,6 +153,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
||||
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
|
||||
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
|
||||
label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '箱唛批次版本,从1递增',
|
||||
batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废',
|
||||
cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹',
|
||||
voided_at DATETIME(3) NULL COMMENT '批次作废时间',
|
||||
box_specification VARCHAR(255) NOT NULL COMMENT '订单箱规或货物明细描述快照',
|
||||
length_cm DECIMAL(12,3) NULL COMMENT '下单箱规长度cm快照',
|
||||
width_cm DECIMAL(12,3) NULL COMMENT '下单箱规宽度cm快照',
|
||||
@@ -160,7 +164,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code),
|
||||
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index),
|
||||
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index),
|
||||
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
|
||||
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
|
||||
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 装柜完成跨区事件使用标准 sha256: 摘要,共 71 字符。
|
||||
ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀';
|
||||
@@ -6,6 +6,26 @@ info:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/merchant/v1/first-leg/inbounds/{inboundId}/carton-label-pdf:
|
||||
post:
|
||||
summary: 客户端生成本客户入库单箱唛 PDF
|
||||
parameters:
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: cartonIndex, in: query, required: false, schema: {type: array, items: {type: integer, minimum: 1}}}
|
||||
responses:
|
||||
'200': {description: PDF 文件}
|
||||
'403': {description: 非客户端身份、无订单查看权限或入库单不属于当前客户}
|
||||
/api/merchant/v1/first-leg/orders/{orderId}/carton-label-pdf:
|
||||
post:
|
||||
summary: 客户端按订单生成本客户草稿或正式订单箱唛 PDF
|
||||
description: 后端按登录客户和 orderId 查找真实预入库单;不要求客户端先取得 inboundId。
|
||||
parameters:
|
||||
- {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: cartonIndex, in: query, required: false, schema: {type: array, items: {type: integer, minimum: 1}}}
|
||||
responses:
|
||||
'200': {description: PDF 文件}
|
||||
'403': {description: 非客户端身份、无订单查看权限或订单不属于当前客户}
|
||||
'404': {description: 订单尚未生成预入库单}
|
||||
/internal/firstleg/v1/customer-dimension-configs/defaults:
|
||||
post:
|
||||
summary: 幂等初始化客户四箱规默认配置
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.service.InboundCartonLabelPrintService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@SpringJUnitConfig(MerchantInboundCartonLabelController.class)
|
||||
class MerchantInboundCartonLabelControllerTest {
|
||||
@MockBean private InboundCartonLabelPrintService service;
|
||||
@Resource private MerchantInboundCartonLabelController controller;
|
||||
|
||||
@Test
|
||||
void usesAuthenticatedCustomerId() throws Exception {
|
||||
AuthenticatedUser merchant = new AuthenticatedUser(1L, 10001L, 0L, 1L,
|
||||
"merchant", "MERCHANT", "WEB", Set.of());
|
||||
|
||||
controller.print(merchant, 88L, null, "req-1");
|
||||
|
||||
verify(service).generate(10001L, 88L, null, "req-1");
|
||||
PreAuthorize permission = MerchantInboundCartonLabelController.class
|
||||
.getDeclaredMethod("print", AuthenticatedUser.class, long.class, java.util.List.class, String.class)
|
||||
.getAnnotation(PreAuthorize.class);
|
||||
assertThat(permission.value()).isEqualTo("hasAuthority('merchant:order:view')");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPlatformIdentity() {
|
||||
AuthenticatedUser platform = new AuthenticatedUser(1L, 0L, 0L, 1L,
|
||||
"admin", "PLATFORM", "WEB", Set.of());
|
||||
assertThatThrownBy(() -> controller.print(platform, 88L, null, null))
|
||||
.isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsByOrderWithoutRequiringAnInboundIdFromTheClient() {
|
||||
AuthenticatedUser merchant = new AuthenticatedUser(1L, 10001L, 0L, 1L,
|
||||
"merchant", "MERCHANT", "WEB", Set.of());
|
||||
|
||||
controller.printByOrder(merchant, 77L, null, "req-2");
|
||||
|
||||
verify(service).generateByOrder(10001L, 77L, null, "req-2");
|
||||
}
|
||||
}
|
||||
+39
@@ -8,6 +8,7 @@ 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.cloud.client.circuitbreaker.NoFallbackAvailableException;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -79,6 +80,44 @@ class InboundCartonLabelPrintServiceTest {
|
||||
.isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertsCircuitBreakerFailureToServiceUnavailable() {
|
||||
when(labels.list(10001L, 70001L, null)).thenReturn(batch());
|
||||
when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
|
||||
.thenThrow(new NoFallbackAvailableException("print unavailable",
|
||||
new IllegalStateException("down")));
|
||||
|
||||
assertThatThrownBy(() -> service.generate(10001L, 70001L, null, null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("打印服务暂不可用,请稍后重试")
|
||||
.extracting(value -> ((BizException) value).getResultCode())
|
||||
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchCombinesSuccessfulOrdersAndReportsIsolatedFailures() {
|
||||
when(labels.requireInboundIdByOrder(10001L, 60001L)).thenReturn(70001L);
|
||||
when(labels.requireInboundIdByOrder(10001L, 60002L))
|
||||
.thenThrow(new BizException(ResultCode.NOT_FOUND, "missing"));
|
||||
when(labels.list(10001L, 70001L, null)).thenReturn(batch());
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.eq("request-batch"), org.mockito.ArgumentMatchers.any()))
|
||||
.thenReturn(new ResponseEntity<>(new byte[] {1, 2, 3}, headers,
|
||||
org.springframework.http.HttpStatus.OK));
|
||||
|
||||
ResponseEntity<byte[]> response = service.generateByOrders(
|
||||
10001L, List.of(60001L, 60002L), "request-batch");
|
||||
|
||||
assertThat(response.getHeaders().getFirst("X-Carton-Label-Order-Count")).isEqualTo("2");
|
||||
assertThat(response.getHeaders().getFirst("X-Carton-Label-Failure-Count")).isEqualTo("1");
|
||||
assertThat(response.getHeaders().getFirst("X-Carton-Label-Failures")).contains("60002:A0404");
|
||||
verify(print).generateInboundMarks(org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.eq("request-batch"), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
private InboundCartonLabels.Batch batch() {
|
||||
InboundCartonLabels.Label label = new InboundCartonLabels.Label(
|
||||
1L, 2L, 1, "音箱", 19, "PCHK2608040174#19-1", 19, 1,
|
||||
|
||||
+135
-24
@@ -28,6 +28,7 @@ import static org.mockito.Mockito.when;
|
||||
class InboundCartonLabelServiceTest {
|
||||
@Mock private InboundCartonLabelRepository repository;
|
||||
@Mock private OrderCargoClient orderCargoClient;
|
||||
@Mock private InboundOrderCreationService inboundOrderCreationService;
|
||||
private InboundCartonLabelService service;
|
||||
|
||||
@BeforeEach
|
||||
@@ -35,6 +36,7 @@ class InboundCartonLabelServiceTest {
|
||||
service = new InboundCartonLabelService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "orderCargoClient", orderCargoClient);
|
||||
ReflectionTestUtils.setField(service, "inboundOrderCreationService", inboundOrderCreationService);
|
||||
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
||||
}
|
||||
|
||||
@@ -42,12 +44,13 @@ class InboundCartonLabelServiceTest {
|
||||
void firstAllocationUsesCargoOrderAndFreezesEveryPhysicalCarton() {
|
||||
InboundOrderEntity inbound = inbound();
|
||||
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
|
||||
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
|
||||
when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
|
||||
var second = cargo(80002L, 2, 2, "50", "30", "20");
|
||||
var first = cargo(80001L, 1, 3, "60", "40", "30");
|
||||
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
||||
.thenReturn(ApiResponse.success(List.of(second, first)));
|
||||
when(repository.initializeOriginalPackageCount(10001L, 70001L, 5)).thenReturn(true);
|
||||
when(repository.nextVersion(10001L, 70001L)).thenReturn(1);
|
||||
List<InboundCartonLabelEntity> inserted = new ArrayList<>();
|
||||
org.mockito.Mockito.doAnswer(invocation -> {
|
||||
inserted.add(invocation.getArgument(0));
|
||||
@@ -58,56 +61,116 @@ class InboundCartonLabelServiceTest {
|
||||
|
||||
assertThat(batch.totalCount()).isEqualTo(5);
|
||||
assertThat(batch.labels()).extracting(label -> label.boxCode())
|
||||
.containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3",
|
||||
"PCHK2608040174#5-4", "PCHK2608040174#5-5");
|
||||
.containsExactly("PCHK2608040174#V1#5-1", "PCHK2608040174#V1#5-2", "PCHK2608040174#V1#5-3",
|
||||
"PCHK2608040174#V1#5-4", "PCHK2608040174#V1#5-5");
|
||||
assertThat(batch.labels()).extracting(label -> label.inboundDetailId())
|
||||
.containsExactly(80001L, 80001L, 80001L, 80002L, 80002L);
|
||||
assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm");
|
||||
assertThat(batch.labels().get(0).cartonText()).isEqualTo("CTNS:5-1");
|
||||
assertThat(inbound.getTotalPackageCount()).isEqualTo(5);
|
||||
assertThat(inserted).hasSize(5);
|
||||
verify(repository).initializeOriginalPackageCount(10001L, 70001L, 5);
|
||||
verify(repository).updatePackageCount(10001L, 70001L, 5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation() {
|
||||
void repeatedAndPartialPrintsReadCurrentVersionWithoutReallocation() {
|
||||
InboundOrderEntity inbound = inbound();
|
||||
inbound.setTotalPackageCount(5);
|
||||
List<InboundCartonLabelEntity> existing = labels(inbound, 5);
|
||||
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
|
||||
when(repository.findByInbound(10001L, 70001L)).thenReturn(existing);
|
||||
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing);
|
||||
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
||||
.thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 5, "60", "40", "30"))));
|
||||
|
||||
var batch = service.list(10001L, 70001L, List.of(5, 2, 2));
|
||||
|
||||
assertThat(batch.totalCount()).isEqualTo(5);
|
||||
assertThat(batch.labels()).extracting(label -> label.cartonIndex()).containsExactly(2, 5);
|
||||
verify(orderCargoClient, never()).listForCartonLabels(any(String.class),
|
||||
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong());
|
||||
verify(repository).initializeLegacyFingerprint(org.mockito.ArgumentMatchers.eq(10001L),
|
||||
org.mockito.ArgumentMatchers.eq(70001L), org.mockito.ArgumentMatchers.eq(1), any(String.class));
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesFrozenMappingWhenOriginalPackageCountDoesNotMatch() {
|
||||
void changedCargoCreatesNewVersion() {
|
||||
InboundOrderEntity inbound = inbound();
|
||||
inbound.setTotalPackageCount(4);
|
||||
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
|
||||
when(repository.findByInbound(10001L, 70001L)).thenReturn(labels(inbound, 5));
|
||||
List<InboundCartonLabelEntity> existing = labels(inbound, 5);
|
||||
existing.forEach(label -> { label.setCargoFingerprint("old"); label.setBatchStatus("ACTIVE"); });
|
||||
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing);
|
||||
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
||||
.thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30"))));
|
||||
when(repository.nextVersion(10001L, 70001L)).thenReturn(2);
|
||||
org.mockito.Mockito.doAnswer(invocation -> null).when(repository).insert(any());
|
||||
var result = service.getOrCreate(10001L, 70001L);
|
||||
assertThat(result.labels()).extracting(label -> label.boxCode())
|
||||
.containsExactly("PCHK2608040174#V2#2-1", "PCHK2608040174#V2#2-2");
|
||||
verify(repository).voidActive(org.mockito.ArgumentMatchers.eq(10001L),
|
||||
org.mockito.ArgumentMatchers.eq(70001L), any());
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("订单总箱数与已冻结箱号数量不一致");
|
||||
@Test
|
||||
void draftCanGenerateLabelsBeforeSubmission() {
|
||||
InboundOrderEntity inbound = inbound();
|
||||
inbound.setProcessStatus("PREPARING");
|
||||
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
|
||||
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
|
||||
when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
|
||||
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
||||
.thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30"))));
|
||||
when(repository.nextVersion(10001L, 70001L)).thenReturn(1);
|
||||
org.mockito.Mockito.doAnswer(invocation -> null).when(repository).insert(any());
|
||||
|
||||
verify(repository, never()).initializeOriginalPackageCount(
|
||||
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(),
|
||||
org.mockito.ArgumentMatchers.anyInt());
|
||||
var result = service.getOrCreate(10001L, 70001L);
|
||||
|
||||
assertThat(result.totalCount()).isEqualTo(2);
|
||||
assertThat(result.labels()).extracting(label -> label.boxCode())
|
||||
.containsExactly("PCHK2608040174#V1#2-1", "PCHK2608040174#V1#2-2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingLabelsCanBeRepeatedAfterLoading() {
|
||||
InboundOrderEntity inbound = inbound();
|
||||
inbound.setProcessStatus("LOADING");
|
||||
List<InboundCartonLabelEntity> existing = labels(inbound, 1);
|
||||
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
|
||||
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing);
|
||||
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
||||
.thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 1, "60", "40", "30"))));
|
||||
var result = service.getOrCreate(10001L, 70001L);
|
||||
assertThat(result.totalCount()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedCargoAfterLoadingVoidsCurrentBatchAndCreatesNextVersion() {
|
||||
InboundOrderEntity inbound = inbound();
|
||||
inbound.setProcessStatus("LOADING");
|
||||
List<InboundCartonLabelEntity> existing = labels(inbound, 1);
|
||||
existing.forEach(label -> {
|
||||
label.setCargoFingerprint("old-fingerprint");
|
||||
label.setBatchStatus("ACTIVE");
|
||||
});
|
||||
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
|
||||
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing);
|
||||
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
||||
.thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30"))));
|
||||
when(repository.nextVersion(10001L, 70001L)).thenReturn(2);
|
||||
|
||||
var result = service.getOrCreate(10001L, 70001L);
|
||||
|
||||
assertThat(result.labels()).extracting(label -> label.boxCode())
|
||||
.containsExactly("PCHK2608040174#V2#2-1", "PCHK2608040174#V2#2-2");
|
||||
verify(repository).voidActive(org.mockito.ArgumentMatchers.eq(10001L),
|
||||
org.mockito.ArgumentMatchers.eq(70001L), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesBoxCodeInsideCustomerBoundary() {
|
||||
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
|
||||
when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label);
|
||||
when(repository.findByBoxCode(10001L, "PCHK2608040174#V1#1-1")).thenReturn(label);
|
||||
|
||||
var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#1-1 ");
|
||||
var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#V1#1-1 ");
|
||||
|
||||
assertThat(resolved.inboundId()).isEqualTo(70001L);
|
||||
assertThat(resolved.inboundDetailId()).isEqualTo(80001L);
|
||||
@@ -115,27 +178,37 @@ class InboundCartonLabelServiceTest {
|
||||
assertThat(resolved.cartonIndex()).isEqualTo(1);
|
||||
assertThat(resolved.totalCartons()).isEqualTo(1);
|
||||
assertThat(resolved.boxSpecification()).isEqualTo("60x40x30cm");
|
||||
verify(repository).findByBoxCode(10001L, "PCHK2608040174#1-1");
|
||||
verify(repository).findByBoxCode(10001L, "PCHK2608040174#V1#1-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesDetailedResolutionForFirstlegConsumers() {
|
||||
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
|
||||
when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label);
|
||||
when(repository.findByBoxCode(10001L, "PCHK2608040174#V1#1-1")).thenReturn(label);
|
||||
|
||||
var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#1-1");
|
||||
var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#V1#1-1");
|
||||
|
||||
assertThat(resolved.orderId()).isEqualTo(60001L);
|
||||
assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#1-1");
|
||||
assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#V1#1-1");
|
||||
assertThat(resolved.cargoItemNo()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsVoidedLabelVersionDuringScanResolution() {
|
||||
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
|
||||
label.setBatchStatus("VOID");
|
||||
when(repository.findByBoxCode(10001L, "PCHK2608040174#V1#1-1")).thenReturn(label);
|
||||
|
||||
assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608040174#V1#1-1"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("箱唛版本已作废,请重新打印");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesFirstAllocationAfterMeasurementSubmission() {
|
||||
InboundOrderEntity inbound = inbound();
|
||||
inbound.setProcessStatus("MEASURE_PENDING");
|
||||
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
|
||||
when(repository.findByInbound(10001L, 70001L)).thenReturn(List.of());
|
||||
|
||||
assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L))
|
||||
.isInstanceOf(BizException.class)
|
||||
@@ -144,6 +217,42 @@ class InboundCartonLabelServiceTest {
|
||||
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesTheRealInboundIdByTenantAndOrder() {
|
||||
InboundOrderEntity inbound = inbound();
|
||||
when(repository.findByOrder(10001L, 60001L)).thenReturn(inbound);
|
||||
|
||||
assertThat(service.requireInboundIdByOrder(10001L, 60001L)).isEqualTo(70001L);
|
||||
verify(repository).findByOrder(10001L, 60001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnOrderWithoutAnOwnedPreparingInbound() {
|
||||
when(repository.findByOrder(10001L, 60001L)).thenReturn(null);
|
||||
when(orderCargoClient.inboundSnapshot("internal-token", 10001L, 60001L))
|
||||
.thenThrow(org.mockito.Mockito.mock(feign.FeignException.class));
|
||||
|
||||
assertThatThrownBy(() -> service.requireInboundIdByOrder(10001L, 60001L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("订单服务暂不可用");
|
||||
}
|
||||
|
||||
@Test
|
||||
void repairsMissingHistoricalInboundFromOwnedOrderSnapshot() {
|
||||
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
|
||||
10001L, 60001L, "FL60001", "PCHK2608040174", 1L, "Customer",
|
||||
41L, "Warehouse", null, null, null, "EXPORT_SEA", null, null,
|
||||
null, null, null, null, null, false, "FBA", null, null, null,
|
||||
null, null, null, 2);
|
||||
InboundOrderEntity repaired = inbound();
|
||||
when(repository.findByOrder(10001L, 60001L)).thenReturn(null, repaired);
|
||||
when(orderCargoClient.inboundSnapshot("internal-token", 10001L, 60001L))
|
||||
.thenReturn(ApiResponse.success(snapshot));
|
||||
|
||||
assertThat(service.requireInboundIdByOrder(10001L, 60001L)).isEqualTo(70001L);
|
||||
verify(inboundOrderCreationService).prepareFromDraft(snapshot);
|
||||
}
|
||||
|
||||
private InboundOrderEntity inbound() {
|
||||
InboundOrderEntity inbound = new InboundOrderEntity();
|
||||
inbound.setId(70001L);
|
||||
@@ -175,9 +284,11 @@ class InboundCartonLabelServiceTest {
|
||||
label.setCargoName("货物1");
|
||||
label.setDetailPackageCount(total);
|
||||
label.setInboundCode(inbound.getInboundCode());
|
||||
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
|
||||
label.setBoxCode(inbound.getInboundCode() + "#V1#" + total + "-" + index);
|
||||
label.setTotalCartonCount(total);
|
||||
label.setCartonIndex(index);
|
||||
label.setLabelVersion(1);
|
||||
label.setBatchStatus("ACTIVE");
|
||||
label.setBoxSpecification("60x40x30cm");
|
||||
label.setLengthCm(new BigDecimal("60"));
|
||||
label.setWidthCm(new BigDecimal("40"));
|
||||
|
||||
+17
@@ -113,6 +113,23 @@ class InboundOrderCreationServiceTest {
|
||||
verify(orders, never()).insert(any(InboundOrderEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPrepareDraftIdempotentlyAndUpgradeSameInboundOnApproval() {
|
||||
InboundOrderEntity prepared = inbound(900L, "RK-CUSTOM-77", "UNPRICED", null, null);
|
||||
prepared.setProcessStatus("PREPARING");
|
||||
when(orders.selectOne(any(Wrapper.class))).thenReturn(null, prepared, prepared);
|
||||
when(orders.update(any(), any(Wrapper.class))).thenReturn(1);
|
||||
when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L));
|
||||
|
||||
long preparedId = service.prepareFromDraft(approvedCommand("RK-CUSTOM-77"));
|
||||
long approvedId = service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"));
|
||||
|
||||
assertThat(preparedId).isNotNull();
|
||||
assertThat(approvedId).isEqualTo(900L);
|
||||
verify(orders, times(1)).insert(any(InboundOrderEntity.class));
|
||||
verify(orders, times(2)).update(any(), any(Wrapper.class));
|
||||
}
|
||||
|
||||
private static InboundOrderEntity inbound(long id, String code, String feeStatus,
|
||||
Long carrierId, String carrierName) {
|
||||
InboundOrderEntity inbound = new InboundOrderEntity();
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.baiyue.firstleg.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.mapper.LoadingCompletedEventMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
|
||||
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.math.BigDecimal;
|
||||
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.when;
|
||||
|
||||
class LoadingCompletedOutboxServiceTest {
|
||||
private final LoadingCompletedEventMapper snapshots = mock(LoadingCompletedEventMapper.class);
|
||||
private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.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,"outboxMapper",outbox);
|
||||
ReflectionTestUtils.setField(service,"objectMapper",mapper);
|
||||
}
|
||||
|
||||
@Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception {
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(row(22,300,302),row(11,200,202),row(11,100,102),row(11,100,101)));
|
||||
service.publish(90L);
|
||||
ArgumentCaptor<InboundOutboxEntity> captor=ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox,org.mockito.Mockito.times(2)).insert(captor.capture());
|
||||
List<InboundOutboxEntity> rows=captor.getAllValues();
|
||||
assertThat(rows).extracting(InboundOutboxEntity::getTenantId).containsExactly(11L,22L);
|
||||
EventEnvelope first=mapper.readValue(rows.get(0).getPayloadJson(),EventEnvelope.class);
|
||||
assertThat(first.eventType()).isEqualTo(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED);
|
||||
assertThat(first.tenantId()).isEqualTo("11");
|
||||
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(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:1");
|
||||
}
|
||||
|
||||
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);
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ class LoadingListServiceTest {
|
||||
private LoadingPickRepository pickRepository;
|
||||
@MockBean
|
||||
private LoadingDocumentFileClient documentFiles;
|
||||
@MockBean
|
||||
private LoadingCompletedOutboxService loadingCompletedOutboxService;
|
||||
@Resource
|
||||
private LoadingListService service;
|
||||
|
||||
@@ -135,6 +137,7 @@ class LoadingListServiceTest {
|
||||
verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"),
|
||||
eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(),
|
||||
eq(9L), eq("operator"), eq("PDA已人工确认装柜完成"));
|
||||
verify(loadingCompletedOutboxService).publish(10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -147,6 +150,7 @@ class LoadingListServiceTest {
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
verify(repository, never()).advance(eq(10L), anyString(), anyString(),
|
||||
org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString());
|
||||
verify(loadingCompletedOutboxService, never()).publish(10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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());}}}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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<String> 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<<Math.min(attempts,7))),safe(error),id,owner); }
|
||||
private String safe(String value) { if(value==null)return "投递失败"; return value.length()<=1000?value:value.substring(0,1000); }
|
||||
public record Row(String eventId,String eventType,String payload,int attempts) { }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.iam.securityevent;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class SecurityEventService {
|
||||
public static final String USER_STATUS_CHANGED="IAM_USER_STATUS_CHANGED_V1";
|
||||
public static final String ACCOUNT_STATUS_CHANGED="IAM_ACCOUNT_STATUS_CHANGED_V1";
|
||||
public static final String PERMISSION_VERSION_CHANGED="IAM_PERMISSION_VERSION_CHANGED_V1";
|
||||
@Resource private SecurityEventRepository repository;
|
||||
@Resource private ObjectMapper json;
|
||||
public void userStatus(long authUserId,long tenantId,long membershipId,String status){append(USER_STATUS_CHANGED,"USER",authUserId,Map.of("authUserId",authUserId,"tenantId",tenantId,"membershipId",membershipId,"status",status));}
|
||||
public void accountStatus(long accountId,String status){append(ACCOUNT_STATUS_CHANGED,"ACCOUNT",accountId,Map.of("accountId",accountId,"status",status));}
|
||||
public long permissionsChanged(long authUserId,long tenantId,long membershipId,String reason){long version=repository.incrementPermissionVersion(authUserId);append(PERMISSION_VERSION_CHANGED,"AUTHORIZATION",authUserId,Map.of("authUserId",authUserId,"tenantId",tenantId,"membershipId",membershipId,"permissionVersion",version,"reason",reason));return version;}
|
||||
private void append(String type,String aggregateType,long aggregateId,Map<String,Object> fields){String id=UUID.randomUUID().toString();Map<String,Object> 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);}}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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_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),
|
||||
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可靠安全事件本地发送箱';
|
||||
@@ -17,6 +17,15 @@ public interface FirstlegInboundClient {
|
||||
ApiResponse<Long> create(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody OrderApprovedInboundCommand command);
|
||||
|
||||
@PostMapping("/order-preparing")
|
||||
ApiResponse<Long> prepare(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody OrderApprovedInboundCommand command);
|
||||
|
||||
@PostMapping("/order-preparing/batch")
|
||||
ApiResponse<List<InboundReference>> prepareBatch(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody List<OrderApprovedInboundCommand> commands);
|
||||
|
||||
@PostMapping("/order-references")
|
||||
ApiResponse<List<InboundReference>> references(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import com.baiyue.order.service.OrderApplicationService;
|
||||
import com.baiyue.order.client.FirstlegInboundClient;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -68,6 +70,25 @@ public class InternalOrderCargoController {
|
||||
.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/{orderId}/inbound-snapshot")
|
||||
public ApiResponse<FirstlegInboundClient.OrderApprovedInboundCommand> inboundSnapshot(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long orderId) {
|
||||
requireToken(token);
|
||||
if (tenantId <= 0 || orderId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0");
|
||||
}
|
||||
var order = orders.findOwned(tenantId, orderId);
|
||||
if (order == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户");
|
||||
}
|
||||
int packageCount = orders.findCargoInheritedByOrder(tenantId, orderId).stream()
|
||||
.map(OrderCargoEntity::getPackageCount).filter(java.util.Objects::nonNull)
|
||||
.mapToInt(Integer::intValue).sum();
|
||||
return ApiResponse.success(OrderApplicationService.inboundCommand(order, packageCount));
|
||||
}
|
||||
|
||||
private void requireToken(String token) {
|
||||
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
|
||||
@@ -12,6 +12,7 @@ public record MerchantInboundOrderRow(
|
||||
String orderNo,
|
||||
Integer packageCount,
|
||||
BigDecimal weightKg,
|
||||
Long inboundId,
|
||||
String inboundCode,
|
||||
String channelName,
|
||||
String destinationType,
|
||||
@@ -36,12 +37,16 @@ public record MerchantInboundOrderRow(
|
||||
Long operatorId,
|
||||
String operatorName,
|
||||
String remark,
|
||||
String destinationWarehouseName,
|
||||
String expressCompany,
|
||||
String skuNos,
|
||||
String shipmentId,
|
||||
List<CargoColumn> cargoes
|
||||
) {
|
||||
public record CargoColumn(
|
||||
Long cargoId, Integer itemNo, String cargoName, Integer packageCount,
|
||||
Integer totalQuantity, BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
||||
BigDecimal volumeCbm, BigDecimal unitDeclaredValue, BigDecimal declaredValue,
|
||||
String currency, String model, String material, String productUsage, String brand) {
|
||||
String currency, String skuNo, String model, String material, String productUsage, String brand) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,21 @@ public class MerchantInboundOrderListService {
|
||||
.stream().collect(Collectors.toMap(
|
||||
OrderStatusHistoryEntity::getOrderId, Function.identity(), (latest, ignored) -> latest));
|
||||
Map<Long, FirstlegInboundClient.InboundReference> inboundReferences = references(tenantId, orderIds);
|
||||
Map<Long, FirstlegInboundClient.InboundReference> initialReferences = inboundReferences;
|
||||
List<OrderEntity> missing = source.records().stream()
|
||||
.filter(order -> !initialReferences.containsKey(order.getId()))
|
||||
.filter(order -> order.getStatus() == com.baiyue.order.entity.OrderStatus.DRAFT
|
||||
|| order.getStatus() == com.baiyue.order.entity.OrderStatus.SUBMITTED)
|
||||
.toList();
|
||||
if (!missing.isEmpty()) {
|
||||
inboundReferences = repairedReferences(tenantId, inboundReferences, missing, cargoes);
|
||||
}
|
||||
Map<Long, FirstlegInboundClient.InboundReference> finalReferences = inboundReferences;
|
||||
|
||||
List<MerchantInboundOrderRow> records = source.records().stream()
|
||||
.map(order -> row(order, cargoes.getOrDefault(order.getId(), List.of()),
|
||||
consignees.get(order.getId()), latestHistory.get(order.getId()),
|
||||
inboundReferences.get(order.getId())))
|
||||
finalReferences.get(order.getId())))
|
||||
.toList();
|
||||
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
@@ -69,6 +79,26 @@ public class MerchantInboundOrderListService {
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Long, FirstlegInboundClient.InboundReference> repairedReferences(
|
||||
long tenantId, Map<Long, FirstlegInboundClient.InboundReference> current,
|
||||
List<OrderEntity> missing, Map<Long, List<OrderCargoEntity>> cargoes) {
|
||||
try {
|
||||
List<FirstlegInboundClient.OrderApprovedInboundCommand> commands = missing.stream().map(order -> {
|
||||
int packages = cargoes.getOrDefault(order.getId(), List.of()).stream()
|
||||
.map(OrderCargoEntity::getPackageCount).filter(Objects::nonNull)
|
||||
.mapToInt(Integer::intValue).sum();
|
||||
return OrderApplicationService.inboundCommand(order, packages);
|
||||
}).toList();
|
||||
var response = firstleg.prepareBatch(internalToken, commands);
|
||||
if (response == null || !response.successful() || response.data() == null) return current;
|
||||
Map<Long, FirstlegInboundClient.InboundReference> result = new java.util.HashMap<>(current);
|
||||
response.data().forEach(reference -> result.put(reference.orderId(), reference));
|
||||
return result;
|
||||
} catch (RuntimeException ignored) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
private MerchantInboundOrderRow row(
|
||||
OrderEntity order,
|
||||
List<OrderCargoEntity> cargoes,
|
||||
@@ -89,13 +119,15 @@ public class MerchantInboundOrderListService {
|
||||
cargo.getId(), cargo.getItemNo(), cargo.getCargoName(), cargo.getPackageCount(),
|
||||
cargo.getTotalQuantity(), cargo.getGrossWeightKg(), cargo.getNetWeightKg(),
|
||||
cargo.getVolumeCbm(), cargo.getUnitDeclaredValue(), cargo.getDeclaredValue(),
|
||||
cargo.getCurrency(), cargo.getModel(), cargo.getMaterial(), cargo.getProductUsage(),
|
||||
cargo.getCurrency(), cargo.getSkuNo(), cargo.getModel(), cargo.getMaterial(), cargo.getProductUsage(),
|
||||
cargo.getBrand()))
|
||||
.toList();
|
||||
|
||||
return new MerchantInboundOrderRow(
|
||||
order.getId(), order.getOrderNo(), packageCount, grossWeight,
|
||||
inbound == null ? null : inbound.inboundCode(), order.getChannelName(),
|
||||
inbound == null ? null : inbound.inboundId(),
|
||||
inbound == null || inbound.inboundCode() == null || inbound.inboundCode().isBlank()
|
||||
? order.getInboundCode() : inbound.inboundCode(), order.getChannelName(),
|
||||
order.getDestinationType(), order.getCustomerName(),
|
||||
consignee == null ? null : consignee.getCompanyName(), joined(cargoes, OrderCargoEntity::getModel),
|
||||
order.getStatus(), totalQuantity, grossWeight, netWeight, volume,
|
||||
@@ -106,6 +138,9 @@ public class MerchantInboundOrderListService {
|
||||
order.getExpressTrackingNo(), order.getShipmentId(), order.getReferenceId(),
|
||||
latestHistory == null ? order.getCreatedBy() : latestHistory.getOperatorId(),
|
||||
latestHistory == null ? null : latestHistory.getOperatorName(), order.getCustomerRemark(),
|
||||
order.getDestinationWarehouseName(), order.getExpressCompany(),
|
||||
joined(cargoes, OrderCargoEntity::getSkuNo),
|
||||
order.getShipmentId(),
|
||||
cargoColumns);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.dto.OrderDetailResponse;
|
||||
@@ -18,6 +19,10 @@ import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -37,6 +42,8 @@ public class OrderApplicationService {
|
||||
@Resource private OrderMasterDataGuard masterDataGuard;
|
||||
@Resource private OrderPricingChannelGuard pricingChannelGuard;
|
||||
@Resource private ProductImageGuard productImageGuard;
|
||||
@Resource private ObjectProvider<FirstlegInboundClient> firstlegProvider;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
|
||||
/** 创建草稿时使用数据库唯一键保证请求幂等。 */
|
||||
@Transactional
|
||||
@@ -63,6 +70,7 @@ public class OrderApplicationService {
|
||||
repository.insertOrder(order);
|
||||
saveChildren(tenantId, userId, order.getId(), request);
|
||||
record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, null);
|
||||
prepareDraftInbound(order, request);
|
||||
return order.getId();
|
||||
}
|
||||
|
||||
@@ -104,6 +112,7 @@ public class OrderApplicationService {
|
||||
repository.replaceDraftChildren(tenantId, id);
|
||||
saveChildren(tenantId, userId, id, request);
|
||||
record(tenantId, id, before, order.getStatus(), "UPDATE", null, userId, null);
|
||||
prepareDraftInbound(order, request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -482,10 +491,10 @@ public class OrderApplicationService {
|
||||
event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now());
|
||||
repository.insertOutbox(event);
|
||||
}
|
||||
static FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) {
|
||||
public static FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) {
|
||||
return inboundCommand(order, null);
|
||||
}
|
||||
static FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order,
|
||||
public static FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order,
|
||||
Integer totalPackageCount) {
|
||||
return new FirstlegInboundClient.OrderApprovedInboundCommand(
|
||||
order.getTenantId(), order.getId(), order.getOrderNo(), order.getInboundCode(),
|
||||
@@ -499,6 +508,28 @@ public class OrderApplicationService {
|
||||
order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(),
|
||||
order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount);
|
||||
}
|
||||
private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) {
|
||||
FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable();
|
||||
if (client == null || !StringUtils.hasText(internalToken)) return;
|
||||
int packageCount = request.cargoes().stream().map(OrderRequests.Cargo::packageCount)
|
||||
.filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||
FirstlegInboundClient.OrderApprovedInboundCommand command = inboundCommand(order, packageCount);
|
||||
Runnable prepare = () -> {
|
||||
try {
|
||||
ApiResponse<Long> response = client.prepare(internalToken, command);
|
||||
if (response == null || !response.successful() || response.data() == null) return;
|
||||
} catch (RuntimeException ignored) {
|
||||
// Repaired by MerchantInboundOrderListService on the next query.
|
||||
}
|
||||
};
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override public void afterCommit() { prepare.run(); }
|
||||
});
|
||||
} else {
|
||||
prepare.run();
|
||||
}
|
||||
}
|
||||
private int totalPackageCount(OrderEntity order) {
|
||||
long total = repository.findCargo(order.getTenantId(), order.getId()).stream()
|
||||
.map(com.baiyue.order.entity.OrderCargoEntity::getPackageCount)
|
||||
|
||||
@@ -665,6 +665,22 @@ components:
|
||||
properties:
|
||||
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
orderNo: {type: string}
|
||||
inboundId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
inboundCode: {type: string}
|
||||
channelName: {type: string}
|
||||
destinationWarehouseName: {type: string}
|
||||
status: {type: string}
|
||||
packageCount: {type: integer}
|
||||
expressCompany: {type: string}
|
||||
trackingNo: {type: string}
|
||||
skuNos: {type: string, description: 多个 SKU 使用中文顿号分隔}
|
||||
totalGrossWeightKg: {type: number}
|
||||
totalNetWeightKg: {type: number}
|
||||
totalVolumeCbm: {type: number}
|
||||
totalQuantity: {type: integer}
|
||||
remark: {type: string}
|
||||
shipmentId: {type: string}
|
||||
referenceId: {type: string}
|
||||
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
|
||||
ContainerSlice:
|
||||
type: object
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -104,6 +105,29 @@ class InternalOrderCargoControllerTest {
|
||||
verify(orders).findCargoInheritedByOrder(11L, 201L, 31L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsOwnedOrderSnapshotForPreparingInboundRepair() {
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(21L);
|
||||
order.setTenantId(11L);
|
||||
order.setOrderNo("FL21");
|
||||
order.setInboundCode("RK-21");
|
||||
order.setCustomerName("Customer");
|
||||
order.setReceivingWarehouseId(41L);
|
||||
order.setReceivingWarehouseName("Warehouse");
|
||||
order.setDestinationType("FBA");
|
||||
order.setTransportMode("SEA");
|
||||
when(orders.findOwned(11L, 21L)).thenReturn(order);
|
||||
when(orders.findCargoInheritedByOrder(11L, 21L))
|
||||
.thenReturn(List.of(cargo(31L, 1, 3, "60", "40", "30")));
|
||||
|
||||
var response = controller.inboundSnapshot("internal-token", 11L, 21L);
|
||||
|
||||
assertThat(response.data().orderId()).isEqualTo(21L);
|
||||
assertThat(response.data().totalPackageCount()).isEqualTo(3);
|
||||
verify(orders).findOwned(11L, 21L);
|
||||
}
|
||||
|
||||
private OrderCargoEntity cargo(long id, int itemNo, int packageCount,
|
||||
String length, String width, String height) {
|
||||
OrderCargoEntity cargo = new OrderCargoEntity();
|
||||
|
||||
+9
-1
@@ -41,8 +41,10 @@ class MerchantInboundOrderListServiceTest {
|
||||
order.setStatus(OrderStatus.APPROVED);
|
||||
order.setChannelName("美森");
|
||||
order.setDestinationType("FBA");
|
||||
order.setDestinationWarehouseName("ONT8");
|
||||
order.setCustomerName("测试客户");
|
||||
order.setShipmentId("FBA-SHIPMENT-77");
|
||||
order.setExpressCompany("UPS");
|
||||
order.setExpressTrackingNo("TRACK-77");
|
||||
order.setCustomerRemark("客户备注");
|
||||
when(repository.merchantInboundPage(10001L, query))
|
||||
@@ -63,10 +65,11 @@ class MerchantInboundOrderListServiceTest {
|
||||
when(repository.findHistoryByOrderIds(10001L, List.of(77L))).thenReturn(List.of(history));
|
||||
when(firstleg.references(org.mockito.ArgumentMatchers.eq("internal-token"),
|
||||
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(List.of(
|
||||
new FirstlegInboundClient.InboundReference(77L, 88L, "RK-77", "WAIT_MEASURE"))));
|
||||
new FirstlegInboundClient.InboundReference(77L, 88L, "RK-77", "INBOUNDED"))));
|
||||
|
||||
var row = service.page(10001L, query).records().get(0);
|
||||
|
||||
assertThat(row.inboundId()).isEqualTo(88L);
|
||||
assertThat(row.inboundCode()).isEqualTo("RK-77");
|
||||
assertThat(row.packageCount()).isEqualTo(5);
|
||||
assertThat(row.totalQuantity()).isEqualTo(50);
|
||||
@@ -78,6 +81,10 @@ class MerchantInboundOrderListServiceTest {
|
||||
assertThat(row.specificationModel()).isEqualTo("A、B");
|
||||
assertThat(row.receivingCompanyName()).isEqualTo("收货公司");
|
||||
assertThat(row.operatorName()).isEqualTo("操作员");
|
||||
assertThat(row.destinationWarehouseName()).isEqualTo("ONT8");
|
||||
assertThat(row.expressCompany()).isEqualTo("UPS");
|
||||
assertThat(row.skuNos()).isEqualTo("SKU-A、SKU-B");
|
||||
assertThat(row.shipmentId()).isEqualTo("FBA-SHIPMENT-77");
|
||||
assertThat(row.cargoes()).hasSize(2);
|
||||
verify(repository).merchantInboundPage(10001L, query);
|
||||
}
|
||||
@@ -97,6 +104,7 @@ class MerchantInboundOrderListServiceTest {
|
||||
cargo.setUnitDeclaredValue(new BigDecimal(unitPrice));
|
||||
cargo.setDeclaredValue(new BigDecimal(amount));
|
||||
cargo.setModel(model);
|
||||
cargo.setSkuNo("SKU-" + model);
|
||||
return cargo;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user