Merge branch 'test' into test-lastlag
# Conflicts: # auth/src/main/java/com/baiyue/auth/service/AuthService.java # auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java
This commit is contained in:
Vendored
+17
-3
@@ -24,6 +24,8 @@ def commonPaths = [
|
|||||||
'Jenkinsfile.service', 'deploy/'
|
'Jenkinsfile.service', 'deploy/'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def databaseMigrationPaths = ['deploy/sql/']
|
||||||
|
|
||||||
def downstreamResults = [:]
|
def downstreamResults = [:]
|
||||||
|
|
||||||
pipeline {
|
pipeline {
|
||||||
@@ -83,6 +85,7 @@ pipeline {
|
|||||||
error('DOWNSTREAM_JOB_PREFIX 不能为空')
|
error('DOWNSTREAM_JOB_PREFIX 不能为空')
|
||||||
}
|
}
|
||||||
env.RESOLVED_JOB_PREFIX = jobPrefix
|
env.RESOLVED_JOB_PREFIX = jobPrefix
|
||||||
|
env.DATABASE_CHANGE_DETECTED = 'false'
|
||||||
|
|
||||||
List selected = []
|
List selected = []
|
||||||
if (params.BUILD_MODE == 'ALL') {
|
if (params.BUILD_MODE == 'ALL') {
|
||||||
@@ -101,8 +104,13 @@ pipeline {
|
|||||||
} else {
|
} else {
|
||||||
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
|
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
|
||||||
.trim().split('\\n').collect { it.trim() }.findAll { it }
|
.trim().split('\\n').collect { it.trim() }.findAll { it }
|
||||||
|
boolean databaseChanged = changed.any { path ->
|
||||||
|
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||||
|
}
|
||||||
|
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
|
||||||
boolean commonChanged = changed.any { path ->
|
boolean commonChanged = changed.any { path ->
|
||||||
commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
|
boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||||
|
!databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
|
||||||
}
|
}
|
||||||
selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
|
selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
|
||||||
changed.any { it == module || it.startsWith("${module}/") }
|
changed.any { it == module || it.startsWith("${module}/") }
|
||||||
@@ -119,13 +127,19 @@ pipeline {
|
|||||||
env.SELECTED_SERVICES = selected.join(',')
|
env.SELECTED_SERVICES = selected.join(',')
|
||||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
currentBuild.description = '没有需要构建的业务服务'
|
currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true'
|
||||||
|
? '仅数据库迁移变更 | 需单独审核执行'
|
||||||
|
: '没有需要构建的业务服务'
|
||||||
} else {
|
} else {
|
||||||
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||||
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||||
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}"
|
String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
|
||||||
|
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
|
||||||
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
|
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
|
||||||
}
|
}
|
||||||
|
if (env.DATABASE_CHANGE_DETECTED == 'true') {
|
||||||
|
echo '检测到 deploy/sql/ 变更:AUTO 不据此扩大服务范围;统一 SQL 须独立审核并执行。'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -12,7 +12,8 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
|
||||||
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
|
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
|
||||||
| Token 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java` |
|
| 小 JWT 签发 | `service/AuthService.java`;JWT 只承载稳定身份、应用域和版本声明,角色/权限随 Redis 会话加载 |
|
||||||
|
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
||||||
| 注册协作 | `service/MerchantRegistrationService.java` |
|
| 注册协作 | `service/MerchantRegistrationService.java` |
|
||||||
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
|
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
|
||||||
| DTO/持久化 | `dto/AccountManagementDtos.java`、`entity/`、`mapper/`、`repository/` |
|
| DTO/持久化 | `dto/AccountManagementDtos.java`、`entity/`、`mapper/`、`repository/` |
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
|
|
||||||
## 修改约束
|
## 修改约束
|
||||||
|
|
||||||
- 密码、Token、敏感 Claim 不得写日志;修改 Claim、Redis Key 或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
|
- 密码、Token、敏感 Claim 不得写日志;小 JWT 不嵌入角色和权限全集,实时授权来自 Redis 会话中的 IAM 授权快照。修改 Claim、Redis Key、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
|
||||||
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
|
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
|
||||||
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
|
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。
|
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。
|
||||||
@@ -108,7 +109,8 @@ public class AuthService {
|
|||||||
String jwtId = UUID.randomUUID().toString();
|
String jwtId = UUID.randomUUID().toString();
|
||||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
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())
|
.subject(account.getId().toString())
|
||||||
.claim("user_id", account.getId())
|
.claim("user_id", account.getId())
|
||||||
.claim("username", account.getUsername())
|
.claim("username", account.getUsername())
|
||||||
@@ -119,6 +121,10 @@ public class AuthService {
|
|||||||
.claim("client_type", authorization.getClientType())
|
.claim("client_type", authorization.getClientType())
|
||||||
.claim("token_version", account.getTokenVersion())
|
.claim("token_version", account.getTokenVersion())
|
||||||
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired()))
|
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired()))
|
||||||
|
.claim("userId", account.getId())
|
||||||
|
.claim("tenantId", account.getDefaultTenantId())
|
||||||
|
.claim("appCode", account.getDefaultAppCode())
|
||||||
|
.claim("permissionVersion", account.getTokenVersion())
|
||||||
.build();
|
.build();
|
||||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
||||||
signingHeader(), claims)).getTokenValue();
|
signingHeader(), claims)).getTokenValue();
|
||||||
@@ -167,12 +173,16 @@ public class AuthService {
|
|||||||
Instant issuedAt = Instant.now();
|
Instant issuedAt = Instant.now();
|
||||||
Instant expiresAt = issuedAt.plus(Duration.ofMinutes(15));
|
Instant expiresAt = issuedAt.plus(Duration.ofMinutes(15));
|
||||||
String jwtId = UUID.randomUUID().toString();
|
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())
|
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
|
||||||
.claim("user_id", account.getId()).claim("username", account.getUsername())
|
.claim("user_id", account.getId()).claim("username", account.getUsername())
|
||||||
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
|
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
|
||||||
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
|
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
|
||||||
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
|
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
|
||||||
|
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
|
||||||
|
.claim("appCode", "MERCHANT")
|
||||||
|
.claim("permissionVersion", account.getTokenVersion())
|
||||||
.claim("impersonated", true).claim("impersonation_id", ticket.ticketId())
|
.claim("impersonated", true).claim("impersonation_id", ticket.ticketId())
|
||||||
.claim("actor_user_id", ticket.actorUserId()).claim("actor_membership_id", ticket.actorMembershipId())
|
.claim("actor_user_id", ticket.actorUserId()).claim("actor_membership_id", ticket.actorMembershipId())
|
||||||
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
|
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class AuthServiceSmallJwtTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void signsDomesticHmacClaimsAndStoresSession() {
|
void signsCrossRegionClaimsAndStoresSession() {
|
||||||
AuthAccount account = new AuthAccount();
|
AuthAccount account = new AuthAccount();
|
||||||
account.setId(42L);
|
account.setId(42L);
|
||||||
account.setUsername("operator");
|
account.setUsername("operator");
|
||||||
@@ -59,7 +59,7 @@ class AuthServiceSmallJwtTest {
|
|||||||
account.setDefaultAppCode("PLATFORM");
|
account.setDefaultAppCode("PLATFORM");
|
||||||
account.setTokenVersion(1);
|
account.setTokenVersion(1);
|
||||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
||||||
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 7L);
|
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"));
|
||||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
|
||||||
when(repository.findByUsername("operator")).thenReturn(account);
|
when(repository.findByUsername("operator")).thenReturn(account);
|
||||||
@@ -74,10 +74,13 @@ class AuthServiceSmallJwtTest {
|
|||||||
verify(jwtEncoder).encode(jwt.capture());
|
verify(jwtEncoder).encode(jwt.capture());
|
||||||
assertThat(jwt.getValue().getClaims().getClaims())
|
assertThat(jwt.getValue().getClaims().getClaims())
|
||||||
.containsEntry("user_id", 42L)
|
.containsEntry("user_id", 42L)
|
||||||
|
.containsEntry("userId", 42L)
|
||||||
|
.containsEntry("tenantId", 0L)
|
||||||
|
.containsEntry("appCode", "PLATFORM")
|
||||||
|
.containsEntry("permissionVersion", 1)
|
||||||
.containsEntry("membership_id", 84L)
|
.containsEntry("membership_id", 84L)
|
||||||
.doesNotContainKeys("userId", "tenantId", "appCode", "roles", "authorities",
|
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
|
||||||
"permissionVersion", "permissions", "scope");
|
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
|
||||||
assertThat(jwt.getValue().getClaims().getAudience()).isNull();
|
|
||||||
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
||||||
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
||||||
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
||||||
@@ -86,4 +89,42 @@ class AuthServiceSmallJwtTest {
|
|||||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(43L);
|
||||||
|
account.setUsername("merchant-owner");
|
||||||
|
account.setAccountStatus("ACTIVE");
|
||||||
|
account.setDefaultTenantId(10001L);
|
||||||
|
account.setOperatorTenantId(0L);
|
||||||
|
account.setDefaultMembershipId(85L);
|
||||||
|
account.setDefaultAppCode("MERCHANT");
|
||||||
|
account.setTokenVersion(2);
|
||||||
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
|
||||||
|
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"));
|
||||||
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket(
|
||||||
|
"ticket-1", 43L, 10001L, 85L, 42L, 84L, "support");
|
||||||
|
|
||||||
|
when(tokenSessionStore.consumeImpersonationTicket("one-time-code")).thenReturn(ticket);
|
||||||
|
when(repository.findById(43L)).thenReturn(account);
|
||||||
|
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||||
|
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||||
|
when(encoded.getTokenValue()).thenReturn("small-impersonation-token");
|
||||||
|
|
||||||
|
service.exchangeImpersonationTicket("one-time-code");
|
||||||
|
|
||||||
|
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||||
|
verify(jwtEncoder).encode(jwt.capture());
|
||||||
|
assertThat(jwt.getValue().getClaims().getClaims())
|
||||||
|
.containsEntry("appCode", "MERCHANT")
|
||||||
|
.containsEntry("impersonated", true)
|
||||||
|
.containsEntry("membership_id", 85L)
|
||||||
|
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
|
||||||
|
verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2657,7 +2657,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
|||||||
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
||||||
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
|
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
|
||||||
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
|
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
|
||||||
label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '箱唛批次版本,从1递增',
|
label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '内部兼容批次字段,固定为1且不进入箱码',
|
||||||
batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废',
|
batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废',
|
||||||
cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹',
|
cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹',
|
||||||
voided_at DATETIME(3) NULL COMMENT '批次作废时间',
|
voided_at DATETIME(3) NULL COMMENT '批次作废时间',
|
||||||
@@ -2674,7 +2674,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
|||||||
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
|
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
|
||||||
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
|
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
|
||||||
|
|
||||||
-- 存量环境可能已在箱唛版本化前建表;CREATE TABLE IF NOT EXISTS 不会补列。
|
-- 存量环境可能缺少稳定箱码映射兼容字段;CREATE TABLE IF NOT EXISTS 不会补列。
|
||||||
DROP PROCEDURE IF EXISTS unified_carton_label_add_column;
|
DROP PROCEDURE IF EXISTS unified_carton_label_add_column;
|
||||||
DELIMITER $$
|
DELIMITER $$
|
||||||
CREATE PROCEDURE unified_carton_label_add_column(IN p_column VARCHAR(64), IN p_definition TEXT)
|
CREATE PROCEDURE unified_carton_label_add_column(IN p_column VARCHAR(64), IN p_definition TEXT)
|
||||||
@@ -2688,7 +2688,7 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END$$
|
END$$
|
||||||
DELIMITER ;
|
DELIMITER ;
|
||||||
CALL unified_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''箱唛批次版本,从1递增'' AFTER carton_index');
|
CALL unified_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''内部兼容批次字段,固定为1且不进入箱码'' AFTER carton_index');
|
||||||
CALL unified_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version');
|
CALL unified_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version');
|
||||||
CALL unified_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status');
|
CALL unified_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status');
|
||||||
CALL unified_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint');
|
CALL unified_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint');
|
||||||
@@ -2699,9 +2699,12 @@ UPDATE fl_inbound_carton_label
|
|||||||
batch_status='ACTIVE'
|
batch_status='ACTIVE'
|
||||||
WHERE label_version IS NULL OR batch_status IS NULL;
|
WHERE label_version IS NULL OR batch_status IS NULL;
|
||||||
|
|
||||||
|
DELETE FROM fl_inbound_carton_label WHERE batch_status='VOID';
|
||||||
|
|
||||||
UPDATE fl_inbound_carton_label
|
UPDATE fl_inbound_carton_label
|
||||||
SET box_code=CONCAT(inbound_code, '#V', label_version, '#', total_carton_count, '-', carton_index)
|
SET label_version=1,
|
||||||
WHERE box_code NOT LIKE CONCAT(inbound_code, '#V%#%');
|
box_code=CONCAT(inbound_code, '#', total_carton_count, '-', carton_index)
|
||||||
|
WHERE batch_status='ACTIVE';
|
||||||
|
|
||||||
SET @ddl = IF(
|
SET @ddl = IF(
|
||||||
EXISTS(SELECT 1 FROM information_schema.statistics
|
EXISTS(SELECT 1 FROM information_schema.statistics
|
||||||
@@ -10259,6 +10262,13 @@ CREATE TABLE IF NOT EXISTS fin_subject (
|
|||||||
UNIQUE KEY uk_fin_subject_code (subject_code)
|
UNIQUE KEY uk_fin_subject_code (subject_code)
|
||||||
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体';
|
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体';
|
||||||
|
|
||||||
|
INSERT INTO fin_subject
|
||||||
|
(subject_code, subject_name, status, version, created_by, updated_by)
|
||||||
|
VALUES
|
||||||
|
('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
subject_name = VALUES(subject_name), status = 'ACTIVE', updated_by = 0;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
|
CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
@@ -13760,7 +13770,7 @@ BEGIN
|
|||||||
AND table_name = 'fl_inbound_carton_label'
|
AND table_name = 'fl_inbound_carton_label'
|
||||||
AND column_name IN ('label_version', 'batch_status', 'cargo_fingerprint', 'voided_at')) <> 4 THEN
|
AND column_name IN ('label_version', 'batch_status', 'cargo_fingerprint', 'voided_at')) <> 4 THEN
|
||||||
SIGNAL SQLSTATE '45000'
|
SIGNAL SQLSTATE '45000'
|
||||||
SET MESSAGE_TEXT = '统一迁移失败:箱唛版本化字段未完整创建';
|
SET MESSAGE_TEXT = '统一迁移失败:稳定箱码映射兼容字段未完整创建';
|
||||||
END IF;
|
END IF;
|
||||||
IF (SELECT COUNT(*) FROM information_schema.statistics
|
IF (SELECT COUNT(*) FROM information_schema.statistics
|
||||||
WHERE table_schema = 'baiyue_firstleg'
|
WHERE table_schema = 'baiyue_firstleg'
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import java.util.List;
|
|||||||
|
|
||||||
@FeignClient(name="firstleg-service", contextId="financeFirstlegCostSnapshotClient", url="${baiyue.clients.firstleg.url:}", path="/internal/firstleg/v1/finance-cost-snapshots")
|
@FeignClient(name="firstleg-service", contextId="financeFirstlegCostSnapshotClient", url="${baiyue.clients.firstleg.url:}", path="/internal/firstleg/v1/finance-cost-snapshots")
|
||||||
public interface FirstlegCostSnapshotClient {
|
public interface FirstlegCostSnapshotClient {
|
||||||
@GetMapping("/customers/{customerId}/containers/{containerId}")
|
@GetMapping("/customers/{customerId}/loading-plans/{loadingPlanId}")
|
||||||
ApiResponse<ContainerSnapshot> container(@RequestHeader("X-Internal-Service-Token") String token,
|
ApiResponse<ContainerSnapshot> loadingPlan(@RequestHeader("X-Internal-Service-Token") String token,
|
||||||
@PathVariable long customerId, @PathVariable long containerId);
|
@PathVariable long customerId,
|
||||||
record ContainerSnapshot(long containerId, long customerId, List<OrderVolume> orders) {}
|
@PathVariable long loadingPlanId);
|
||||||
|
record ContainerSnapshot(long loadingPlanId, long containerBookingId, long customerId,
|
||||||
|
List<OrderVolume> orders) {}
|
||||||
record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {}
|
record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,12 +175,20 @@ public class PlatformFinanceController {
|
|||||||
java.util.List<FinanceService.ContainerOrderVolume> volumes = java.util.List.of();
|
java.util.List<FinanceService.ContainerOrderVolume> volumes = java.util.List.of();
|
||||||
if ("CONTAINER_SHARED".equals(request.costType())) {
|
if ("CONTAINER_SHARED".equals(request.costType())) {
|
||||||
if (request.containerId() == null) throw new IllegalArgumentException("整柜成本必须指定柜次");
|
if (request.containerId() == null) throw new IllegalArgumentException("整柜成本必须指定柜次");
|
||||||
var response = costSnapshotClient.container(fileToken, request.customerId(), request.containerId());
|
var response = costSnapshotClient.loadingPlan(fileToken, request.customerId(), request.containerId());
|
||||||
var snapshot = response == null ? null : response.data();
|
var snapshot = response == null ? null : response.data();
|
||||||
if (snapshot == null || snapshot.customerId() != request.customerId() || snapshot.containerId() != request.containerId() || snapshot.orders() == null || snapshot.orders().isEmpty()) {
|
if (response == null || !response.successful()) {
|
||||||
|
throw new IllegalArgumentException(response == null || response.message() == null
|
||||||
|
? "Firstleg柜内订单体积快照不可用" : response.message());
|
||||||
|
}
|
||||||
|
if (snapshot == null || snapshot.customerId() != request.customerId()
|
||||||
|
|| snapshot.loadingPlanId() != request.containerId()
|
||||||
|
|| snapshot.containerBookingId() <= 0
|
||||||
|
|| snapshot.orders() == null || snapshot.orders().isEmpty()) {
|
||||||
throw new IllegalArgumentException("Firstleg未返回有效柜内订单体积快照");
|
throw new IllegalArgumentException("Firstleg未返回有效柜内订单体积快照");
|
||||||
}
|
}
|
||||||
volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(), v.inboundId(), snapshot.containerId(), snapshot.customerId(), v.volumeCbm())).toList();
|
volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(),
|
||||||
|
v.inboundId(), snapshot.loadingPlanId(), snapshot.customerId(), v.volumeCbm())).toList();
|
||||||
}
|
}
|
||||||
return FinanceResponses.success(resourceView(service.createCost(request.customerId(), user.userId(), internal, volumes, key)));
|
return FinanceResponses.success(resourceView(service.createCost(request.customerId(), user.userId(), internal, volumes, key)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ import com.baiyue.common.core.api.ResultCode;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.baiyue.finance.client.FinanceFileClient;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
import org.springframework.jdbc.support.KeyHolder;
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
@@ -26,14 +24,12 @@ import java.util.Map;
|
|||||||
public class FinanceService {
|
public class FinanceService {
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
@Autowired(required = false)
|
private final FinanceVoucherValidator voucherValidator;
|
||||||
private FinanceFileClient fileClient;
|
|
||||||
@org.springframework.beans.factory.annotation.Value("${baiyue.security.internal-service-token:}")
|
|
||||||
private String fileToken;
|
|
||||||
|
|
||||||
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.voucherValidator = voucherValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> customerTerms(long tenantId) {
|
public Map<String, Object> customerTerms(long tenantId) {
|
||||||
@@ -203,20 +199,8 @@ public class FinanceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void validateVoucherFiles(long tenantId, List<String> fileIds) {
|
private void validateVoucherFiles(long tenantId, List<String> fileIds) {
|
||||||
if (fileIds == null || fileIds.isEmpty()) throw bad("财务凭证不能为空");
|
voucherValidator.validate(tenantId, fileIds, "财务凭证");
|
||||||
if (fileClient == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "File服务校验能力不可用");
|
|
||||||
for (String raw : fileIds) {
|
|
||||||
long id; try { id = Long.parseLong(raw); } catch (Exception ex) { throw bad("凭证文件ID无效"); }
|
|
||||||
var response = fileClient.metadata(fileToken, tenantId, tenantId, id);
|
|
||||||
var metadata = response == null ? null : response.data();
|
|
||||||
if (metadata == null || metadata.fileId() == null || !tenantIdEquals(metadata.tenantId(), tenantId)
|
|
||||||
|| !"FINANCE_VOUCHER".equals(metadata.businessType()) || metadata.contentType() == null
|
|
||||||
|| !metadata.contentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) {
|
|
||||||
throw bad("凭证文件不存在、归属不匹配或不是有效图片");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
private static boolean tenantIdEquals(Long actual, long expected) { return actual != null && actual == expected; }
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Map<String, Object> createCost(long tenantId, long operatorId, CostRequest request, String idempotencyKey) {
|
public Map<String, Object> createCost(long tenantId, long operatorId, CostRequest request, String idempotencyKey) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.baiyue.finance.service;
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
import com.baiyue.finance.client.FinanceFileClient;
|
|
||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementRequest;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementRequest;
|
||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest;
|
||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
|
||||||
@@ -14,7 +13,6 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
|||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.MDC;
|
import org.slf4j.MDC;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
import org.springframework.jdbc.support.KeyHolder;
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
@@ -47,15 +45,12 @@ public class FinanceSettlementService {
|
|||||||
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final FinanceFileClient fileClient;
|
private final FinanceVoucherValidator voucherValidator;
|
||||||
|
|
||||||
@Value("${baiyue.security.internal-service-token:}")
|
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator) {
|
||||||
private String fileToken;
|
|
||||||
|
|
||||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceFileClient fileClient) {
|
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.fileClient = fileClient;
|
this.voucherValidator = voucherValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -355,26 +350,7 @@ public class FinanceSettlementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void validateVoucherFiles(long customerId, List<String> fileIds) {
|
private void validateVoucherFiles(long customerId, List<String> fileIds) {
|
||||||
if (fileClient == null || !StringUtils.hasText(fileToken)) throw error("FIN_503", "File服务校验能力不可用");
|
voucherValidator.validate(customerId, fileIds, "付款凭证");
|
||||||
for (String raw : fileIds) {
|
|
||||||
long fileId;
|
|
||||||
try { fileId = Long.parseLong(raw); } catch (RuntimeException invalid) { throw error("FIN_400", "付款凭证文件ID无效"); }
|
|
||||||
FinanceFileClient.Metadata metadata;
|
|
||||||
try {
|
|
||||||
var response = fileClient.metadata(fileToken, customerId, customerId, fileId);
|
|
||||||
metadata = response == null ? null : response.data();
|
|
||||||
} catch (RuntimeException unavailable) {
|
|
||||||
throw error("FIN_503", "File服务校验不可用");
|
|
||||||
}
|
|
||||||
if (metadata == null || metadata.fileId() == null || metadata.fileId() != fileId
|
|
||||||
|| metadata.tenantId() == null || metadata.tenantId() != customerId
|
|
||||||
|| !"FINANCE_VOUCHER".equals(metadata.businessType())
|
|
||||||
|| !String.valueOf(customerId).equals(metadata.businessId())
|
|
||||||
|| !StringUtils.hasText(metadata.contentType())
|
|
||||||
|| !metadata.contentType().toLowerCase(Locale.ROOT).startsWith("image/")) {
|
|
||||||
throw error("FIN_400", "付款凭证不存在、客户归属不匹配或文件类型无效");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long reserve(long customerId, long operatorId, String operation, String key,
|
private Long reserve(long customerId, long operatorId, String operation, String key,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.finance.client.FinanceFileClient;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.HashSet;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class FinanceVoucherValidator {
|
||||||
|
private final FinanceFileClient fileClient;
|
||||||
|
private final String fileToken;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public FinanceVoucherValidator(FinanceFileClient fileClient,
|
||||||
|
@Value("${baiyue.security.internal-service-token:}") String fileToken) {
|
||||||
|
this.fileClient = fileClient;
|
||||||
|
this.fileToken = fileToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void validate(long customerId, List<String> fileIds, String message) {
|
||||||
|
if (fileIds == null || fileIds.isEmpty()) throw bad(message + "不能为空");
|
||||||
|
if (fileClient == null || !StringUtils.hasText(fileToken)) throw unavailable("File服务校验能力不可用");
|
||||||
|
if (new HashSet<>(fileIds).size() != fileIds.size()) throw bad(message + "文件ID不能重复");
|
||||||
|
for (String raw : fileIds) {
|
||||||
|
final long fileId;
|
||||||
|
try { fileId = Long.parseLong(raw); } catch (RuntimeException invalid) { throw bad(message + "文件ID无效"); }
|
||||||
|
FinanceFileClient.Metadata metadata;
|
||||||
|
try {
|
||||||
|
var response = fileClient.metadata(fileToken, customerId, customerId, fileId);
|
||||||
|
metadata = response == null ? null : response.data();
|
||||||
|
} catch (RuntimeException unavailable) {
|
||||||
|
throw unavailable("File服务校验不可用");
|
||||||
|
}
|
||||||
|
if (metadata == null || metadata.fileId() == null || metadata.fileId() != fileId
|
||||||
|
|| metadata.tenantId() == null || metadata.tenantId() != customerId
|
||||||
|
|| !String.valueOf(customerId).equals(metadata.businessId())
|
||||||
|
|| !"FINANCE_VOUCHER".equals(metadata.businessType())
|
||||||
|
|| !StringUtils.hasText(metadata.contentType())
|
||||||
|
|| !metadata.contentType().toLowerCase(Locale.ROOT).startsWith("image/")) {
|
||||||
|
throw bad(message + "不存在、归属不匹配或不是有效图片");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||||
|
private static BizException unavailable(String message) {
|
||||||
|
return new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,13 @@ CREATE TABLE IF NOT EXISTS fin_subject (
|
|||||||
UNIQUE KEY uk_fin_subject_code (subject_code)
|
UNIQUE KEY uk_fin_subject_code (subject_code)
|
||||||
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体';
|
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体';
|
||||||
|
|
||||||
|
INSERT INTO fin_subject
|
||||||
|
(subject_code, subject_name, status, version, created_by, updated_by)
|
||||||
|
VALUES
|
||||||
|
('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
subject_name = VALUES(subject_name), status = 'ACTIVE', updated_by = 0;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
|
CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class FinanceContractTest {
|
|||||||
assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')"));
|
assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')"));
|
||||||
String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java"));
|
String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java"));
|
||||||
assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile"));
|
assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile"));
|
||||||
assertTrue(controller.contains("costSnapshotClient.container") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
|
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
|
||||||
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
|
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
|
||||||
String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java"));
|
String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java"));
|
||||||
assertTrue(operations.contains("bl.source_id=f.id"));
|
assertTrue(operations.contains("bl.source_id=f.id"));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.baiyue.finance;
|
package com.baiyue.finance;
|
||||||
|
|
||||||
import com.baiyue.finance.service.FinanceService;
|
import com.baiyue.finance.service.FinanceService;
|
||||||
|
import com.baiyue.finance.service.FinanceVoucherValidator;
|
||||||
import com.baiyue.finance.client.FinanceFileClient;
|
import com.baiyue.finance.client.FinanceFileClient;
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
@@ -18,7 +19,6 @@ import static org.mockito.ArgumentMatchers.any;
|
|||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
|
|
||||||
class FinanceServiceValidationTest {
|
class FinanceServiceValidationTest {
|
||||||
@Test
|
@Test
|
||||||
@@ -49,7 +49,7 @@ class FinanceServiceValidationTest {
|
|||||||
@Test
|
@Test
|
||||||
void rejectsUnsupportedCostTypeBeforeWrite() {
|
void rejectsUnsupportedCostTypeBeforeWrite() {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
FinanceService service = service(jdbc);
|
||||||
var request = new FinanceService.CostRequest("C-1", "TICKET", 1L, null, null, null, null, "MANUAL", "拖车", BigDecimal.ONE, List.of("f"), null);
|
var request = new FinanceService.CostRequest("C-1", "TICKET", 1L, null, null, null, null, "MANUAL", "拖车", BigDecimal.ONE, List.of("f"), null);
|
||||||
assertThrows(RuntimeException.class, () -> service.createCost(1L, 2L, request, "k1"));
|
assertThrows(RuntimeException.class, () -> service.createCost(1L, 2L, request, "k1"));
|
||||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), (Object[]) any());
|
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), (Object[]) any());
|
||||||
@@ -58,7 +58,7 @@ class FinanceServiceValidationTest {
|
|||||||
@Test
|
@Test
|
||||||
void rejectsNonPositiveAllocation() {
|
void rejectsNonPositiveAllocation() {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
FinanceService service = service(jdbc);
|
||||||
var request = new FinanceService.ReceiptRequest("R-1", 1L, null, "BANK_TRANSFER", null, List.of("f"), null,
|
var request = new FinanceService.ReceiptRequest("R-1", 1L, null, "BANK_TRANSFER", null, List.of("f"), null,
|
||||||
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ZERO)));
|
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ZERO)));
|
||||||
assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k2"));
|
assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k2"));
|
||||||
@@ -67,7 +67,7 @@ class FinanceServiceValidationTest {
|
|||||||
@Test
|
@Test
|
||||||
void rejectsDuplicateBillAllocationInsteadOfMerging() {
|
void rejectsDuplicateBillAllocationInsteadOfMerging() {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
FinanceService service = service(jdbc);
|
||||||
var request = new FinanceService.ReceiptRequest("R-2", 1L, null, "BANK_TRANSFER", null, List.of("f"), null,
|
var request = new FinanceService.ReceiptRequest("R-2", 1L, null, "BANK_TRANSFER", null, List.of("f"), null,
|
||||||
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE), new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
|
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE), new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
|
||||||
assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k3"));
|
assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k3"));
|
||||||
@@ -76,7 +76,8 @@ class FinanceServiceValidationTest {
|
|||||||
@Test
|
@Test
|
||||||
void receiptFailsClosedWhenFileValidationCapabilityIsUnavailable() {
|
void receiptFailsClosedWhenFileValidationCapabilityIsUnavailable() {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
FinanceService service = new FinanceService(jdbc, new ObjectMapper(),
|
||||||
|
new FinanceVoucherValidator(null, ""));
|
||||||
var request = new FinanceService.ReceiptRequest("R-FILE", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
|
var request = new FinanceService.ReceiptRequest("R-FILE", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
|
||||||
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
|
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
|
||||||
|
|
||||||
@@ -92,8 +93,7 @@ class FinanceServiceValidationTest {
|
|||||||
FinanceFileClient files = mock(FinanceFileClient.class);
|
FinanceFileClient files = mock(FinanceFileClient.class);
|
||||||
when(files.metadata(anyString(), eq(9L), eq(9L), eq(77L))).thenReturn(ApiResponse.success(
|
when(files.metadata(anyString(), eq(9L), eq(9L), eq(77L))).thenReturn(ApiResponse.success(
|
||||||
new FinanceFileClient.Metadata(77L, 10L, "FINANCE_VOUCHER", "9", "v.jpg", "sha", "image/jpeg", 10L)));
|
new FinanceFileClient.Metadata(77L, 10L, "FINANCE_VOUCHER", "9", "v.jpg", "sha", "image/jpeg", 10L)));
|
||||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
|
FinanceService service = service(jdbc, files);
|
||||||
ReflectionTestUtils.setField(service, "fileClient", files);
|
|
||||||
var request = new FinanceService.ReceiptRequest("R-3", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
|
var request = new FinanceService.ReceiptRequest("R-3", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
|
||||||
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
|
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
|
||||||
assertThrows(RuntimeException.class, () -> service.createReceipt(9L, 2L, request, "k4"));
|
assertThrows(RuntimeException.class, () -> service.createReceipt(9L, 2L, request, "k4"));
|
||||||
@@ -117,9 +117,7 @@ class FinanceServiceValidationTest {
|
|||||||
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
|
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
|
||||||
return 1;
|
return 1;
|
||||||
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
|
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
|
||||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
|
FinanceService service = service(jdbc, files);
|
||||||
ReflectionTestUtils.setField(service, "fileClient", files);
|
|
||||||
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
|
|
||||||
var request = new FinanceService.ReceiptRequest("R-4", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0),
|
var request = new FinanceService.ReceiptRequest("R-4", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0),
|
||||||
"BANK_TRANSFER", null, List.of("77"), "首笔收款",
|
"BANK_TRANSFER", null, List.of("77"), "首笔收款",
|
||||||
List.of(new FinanceService.ReceiptAllocation(50L, new BigDecimal("40.00"))));
|
List.of(new FinanceService.ReceiptAllocation(50L, new BigDecimal("40.00"))));
|
||||||
@@ -154,9 +152,7 @@ class FinanceServiceValidationTest {
|
|||||||
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
|
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
|
||||||
return 1;
|
return 1;
|
||||||
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
|
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
|
||||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
|
FinanceService service = service(jdbc, files);
|
||||||
ReflectionTestUtils.setField(service, "fileClient", files);
|
|
||||||
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
|
|
||||||
var request = new FinanceService.ReceiptRequest("R-300", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0),
|
var request = new FinanceService.ReceiptRequest("R-300", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0),
|
||||||
"BANK_TRANSFER", "BANK-300", List.of("77"), "三单收款", List.of(
|
"BANK_TRANSFER", "BANK-300", List.of("77"), "三单收款", List.of(
|
||||||
new FinanceService.ReceiptAllocation(50L, new BigDecimal("100.00")),
|
new FinanceService.ReceiptAllocation(50L, new BigDecimal("100.00")),
|
||||||
@@ -176,7 +172,7 @@ class FinanceServiceValidationTest {
|
|||||||
void rejectsSingleOrderWhenOrderNotOwnedByCustomer() {
|
void rejectsSingleOrderWhenOrderNotOwnedByCustomer() {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Number.class), any(), any())).thenReturn(0);
|
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Number.class), any(), any())).thenReturn(0);
|
||||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
FinanceService service = service(jdbc);
|
||||||
var request = new FinanceService.CostRequest("C-2", "SINGLE_ORDER", 99L, null, null, null, null, "MANUAL", "货拉拉", BigDecimal.ONE, List.of("1"), null);
|
var request = new FinanceService.CostRequest("C-2", "SINGLE_ORDER", 99L, null, null, null, null, "MANUAL", "货拉拉", BigDecimal.ONE, List.of("1"), null);
|
||||||
assertThrows(RuntimeException.class, () -> service.createCost(9L, 2L, request, "k5"));
|
assertThrows(RuntimeException.class, () -> service.createCost(9L, 2L, request, "k5"));
|
||||||
}
|
}
|
||||||
@@ -198,9 +194,7 @@ class FinanceServiceValidationTest {
|
|||||||
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
|
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
|
||||||
return 1;
|
return 1;
|
||||||
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
|
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
|
||||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
FinanceService service = service(jdbc, files);
|
||||||
ReflectionTestUtils.setField(service, "fileClient", files);
|
|
||||||
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
|
|
||||||
var request = new FinanceService.CostRequest("C-3", "CONTAINER_SHARED", null, null, 88L,
|
var request = new FinanceService.CostRequest("C-3", "CONTAINER_SHARED", null, null, 88L,
|
||||||
null, null, "MANUAL", "整柜成本", new BigDecimal("300.00"), List.of("77"), null);
|
null, null, "MANUAL", "整柜成本", new BigDecimal("300.00"), List.of("77"), null);
|
||||||
var snapshot = List.of(new FinanceService.ContainerOrderVolume(
|
var snapshot = List.of(new FinanceService.ContainerOrderVolume(
|
||||||
@@ -221,7 +215,7 @@ class FinanceServiceValidationTest {
|
|||||||
.thenReturn(1);
|
.thenReturn(1);
|
||||||
var rows = List.<java.util.Map<String, Object>>of(java.util.Map.of("version", 2));
|
var rows = List.<java.util.Map<String, Object>>of(java.util.Map.of("version", 2));
|
||||||
when(jdbc.queryForList(startsWith("SELECT id,bill_id,version"), eq(9L), eq(50L))).thenReturn(rows);
|
when(jdbc.queryForList(startsWith("SELECT id,bill_id,version"), eq(9L), eq(50L))).thenReturn(rows);
|
||||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
FinanceService service = service(jdbc);
|
||||||
|
|
||||||
assertEquals(rows, service.billRevisions(9L, 50L));
|
assertEquals(rows, service.billRevisions(9L, 50L));
|
||||||
}
|
}
|
||||||
@@ -231,9 +225,19 @@ class FinanceServiceValidationTest {
|
|||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill"), eq(Number.class), eq(50L), eq(9L)))
|
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill"), eq(Number.class), eq(50L), eq(9L)))
|
||||||
.thenReturn(0);
|
.thenReturn(0);
|
||||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
FinanceService service = service(jdbc);
|
||||||
|
|
||||||
assertThrows(RuntimeException.class, () -> service.billRevisions(9L, 50L));
|
assertThrows(RuntimeException.class, () -> service.billRevisions(9L, 50L));
|
||||||
verify(jdbc, never()).queryForList(startsWith("SELECT id,bill_id,version"), (Object[]) any());
|
verify(jdbc, never()).queryForList(startsWith("SELECT id,bill_id,version"), (Object[]) any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static FinanceService service(JdbcTemplate jdbc, FinanceFileClient files) {
|
||||||
|
return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||||
|
new FinanceVoucherValidator(files, "internal-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceService service(JdbcTemplate jdbc) {
|
||||||
|
return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||||
|
mock(FinanceVoucherValidator.class));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.baiyue.finance;
|
package com.baiyue.finance;
|
||||||
|
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.finance.client.FinanceFileClient;
|
import com.baiyue.finance.client.FinanceFileClient;
|
||||||
import com.baiyue.finance.controller.FinanceSettlementController;
|
import com.baiyue.finance.controller.FinanceSettlementController;
|
||||||
@@ -12,6 +14,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
|||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
||||||
import com.baiyue.finance.service.FinanceEventException;
|
import com.baiyue.finance.service.FinanceEventException;
|
||||||
import com.baiyue.finance.service.FinanceSettlementService;
|
import com.baiyue.finance.service.FinanceSettlementService;
|
||||||
|
import com.baiyue.finance.service.FinanceVoucherValidator;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -136,8 +139,11 @@ class FinanceSettlementBehaviorTest {
|
|||||||
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenThrow(new IllegalStateException("down"));
|
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenThrow(new IllegalStateException("down"));
|
||||||
FinanceSettlementService service = service(jdbc, fileClient);
|
FinanceSettlementService service = service(jdbc, fileClient);
|
||||||
|
|
||||||
assertCode(() -> service.createSettlementPayment(88L, 7L, "pay-key",
|
assertThatThrownBy(() -> service.createSettlementPayment(88L, 7L, "pay-key",
|
||||||
payment(new BigDecimal("40.00"), 0)), "FIN_503");
|
payment(new BigDecimal("40.00"), 0)))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.extracting(error -> ((BizException) error).getResultCode())
|
||||||
|
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
|
||||||
|
|
||||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any());
|
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any());
|
||||||
verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET paid_amount"), (Object[]) any());
|
verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET paid_amount"), (Object[]) any());
|
||||||
@@ -516,10 +522,8 @@ class FinanceSettlementBehaviorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient) {
|
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient) {
|
||||||
FinanceSettlementService service = new FinanceSettlementService(
|
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||||
jdbc, new ObjectMapper().findAndRegisterModules(), fileClient);
|
new FinanceVoucherValidator(fileClient, "internal-token"));
|
||||||
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
|
|
||||||
return service;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CreateSettlementPaymentRequest payment(BigDecimal amount, int version) {
|
private static CreateSettlementPaymentRequest payment(BigDecimal amount, int version) {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.baiyue.finance;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class FinanceSubjectSeedContractTest {
|
||||||
|
@Test
|
||||||
|
void moduleAndUnifiedSqlSeedOneActivePlatformSubjectIdempotently() throws Exception {
|
||||||
|
String module = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||||
|
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
for (String sql : new String[] {module, unified}) {
|
||||||
|
assertThat(sql)
|
||||||
|
.contains("INSERT INTO fin_subject")
|
||||||
|
.contains("('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)")
|
||||||
|
.contains("ON DUPLICATE KEY UPDATE")
|
||||||
|
.contains("status = 'ACTIVE'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.baiyue.finance;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.finance.client.FinanceFileClient;
|
||||||
|
import com.baiyue.finance.service.FinanceVoucherValidator;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class FinanceVoucherValidatorTest {
|
||||||
|
@Test
|
||||||
|
void acceptsCustomerOwnedFinanceImage() {
|
||||||
|
FinanceFileClient files = mock(FinanceFileClient.class);
|
||||||
|
when(files.metadata("token", 9L, 9L, 77L)).thenReturn(ApiResponse.success(
|
||||||
|
new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L)));
|
||||||
|
|
||||||
|
new FinanceVoucherValidator(files, "token").validate(9L, List.of("77"), "财务凭证");
|
||||||
|
verify(files).metadata("token", 9L, 9L, 77L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMismatchedFileIdentityOwnershipTypeAndMime() {
|
||||||
|
FinanceFileClient files = mock(FinanceFileClient.class);
|
||||||
|
List<FinanceFileClient.Metadata> invalid = List.of(
|
||||||
|
new FinanceFileClient.Metadata(78L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L),
|
||||||
|
new FinanceFileClient.Metadata(77L, 10L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L),
|
||||||
|
new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "10", "receipt.jpg", "sha", "image/jpeg", 10L),
|
||||||
|
new FinanceFileClient.Metadata(77L, 9L, "OTHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L),
|
||||||
|
new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.pdf", "sha", "application/pdf", 10L));
|
||||||
|
|
||||||
|
for (FinanceFileClient.Metadata metadata : invalid) {
|
||||||
|
when(files.metadata(eq("token"), eq(9L), eq(9L), eq(77L))).thenReturn(ApiResponse.success(metadata));
|
||||||
|
assertThatThrownBy(() -> new FinanceVoucherValidator(files, "token")
|
||||||
|
.validate(9L, List.of("77"), "付款凭证"))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failsClosedWhenFileDependencyUnavailable() {
|
||||||
|
FinanceFileClient files = mock(FinanceFileClient.class);
|
||||||
|
when(files.metadata("token", 9L, 9L, 77L)).thenThrow(new IllegalStateException("down"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> new FinanceVoucherValidator(files, "token")
|
||||||
|
.validate(9L, List.of("77"), "付款凭证"))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingInvalidAndDuplicateFileIds() {
|
||||||
|
FinanceVoucherValidator validator = new FinanceVoucherValidator(mock(FinanceFileClient.class), "token");
|
||||||
|
assertResult(() -> validator.validate(9L, List.of(), "财务凭证"), ResultCode.BAD_REQUEST);
|
||||||
|
assertResult(() -> validator.validate(9L, List.of("invalid"), "财务凭证"), ResultCode.BAD_REQUEST);
|
||||||
|
assertResult(() -> validator.validate(9L, List.of("77", "77"), "付款凭证"), ResultCode.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsWrongFileIdAndCustomerOwnership() {
|
||||||
|
FinanceFileClient files = mock(FinanceFileClient.class);
|
||||||
|
when(files.metadata("token", 9L, 9L, 77L)).thenReturn(ApiResponse.success(
|
||||||
|
new FinanceFileClient.Metadata(78L, 10L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L)));
|
||||||
|
assertResult(() -> new FinanceVoucherValidator(files, "token")
|
||||||
|
.validate(9L, List.of("77"), "付款凭证"), ResultCode.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingToken() {
|
||||||
|
assertResult(() -> new FinanceVoucherValidator(mock(FinanceFileClient.class), "")
|
||||||
|
.validate(9L, List.of("77"), "财务凭证"), ResultCode.SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertResult(org.assertj.core.api.ThrowableAssert.ThrowingCallable call,
|
||||||
|
ResultCode resultCode) {
|
||||||
|
assertThatThrownBy(call).isInstanceOf(BizException.class)
|
||||||
|
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(resultCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-5
@@ -14,6 +14,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -45,15 +46,15 @@ class PlatformFinanceControllerBehaviorTest {
|
|||||||
var request = new PlatformFinanceController.CostRequest(
|
var request = new PlatformFinanceController.CostRequest(
|
||||||
10L, "CONTAINER_SHARED", null, 88L, "清关费", new BigDecimal("300.00"),
|
10L, "CONTAINER_SHARED", null, 88L, "清关费", new BigDecimal("300.00"),
|
||||||
"CNY", List.of("91"), "整柜成本");
|
"CNY", List.of("91"), "整柜成本");
|
||||||
var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 10L, List.of(
|
var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 99L, 10L, List.of(
|
||||||
new FirstlegCostSnapshotClient.OrderVolume(101L, 1001L, new BigDecimal("1.25")),
|
new FirstlegCostSnapshotClient.OrderVolume(101L, 1001L, new BigDecimal("1.25")),
|
||||||
new FirstlegCostSnapshotClient.OrderVolume(102L, 1002L, new BigDecimal("2.75"))));
|
new FirstlegCostSnapshotClient.OrderVolume(102L, 1002L, new BigDecimal("2.75"))));
|
||||||
when(snapshotClient.container("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot));
|
when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot));
|
||||||
when(service.createCost(eq(10L), eq(7L), any(), any(), eq("cost-key"))).thenReturn(Map.of("id", 1L));
|
when(service.createCost(eq(10L), eq(7L), any(), any(), eq("cost-key"))).thenReturn(Map.of("id", 1L));
|
||||||
|
|
||||||
controller.cost(user, "cost-key", request);
|
controller.cost(user, "cost-key", request);
|
||||||
|
|
||||||
verify(snapshotClient).container("internal-token", 10L, 88L);
|
verify(snapshotClient).loadingPlan("internal-token", 10L, 88L);
|
||||||
verify(service).createCost(eq(10L), eq(7L), any(), eq(List.of(
|
verify(service).createCost(eq(10L), eq(7L), any(), eq(List.of(
|
||||||
new FinanceService.ContainerOrderVolume(101L, 1001L, 88L, 10L, new BigDecimal("1.25")),
|
new FinanceService.ContainerOrderVolume(101L, 1001L, 88L, 10L, new BigDecimal("1.25")),
|
||||||
new FinanceService.ContainerOrderVolume(102L, 1002L, 88L, 10L, new BigDecimal("2.75")))), eq("cost-key"));
|
new FinanceService.ContainerOrderVolume(102L, 1002L, 88L, 10L, new BigDecimal("2.75")))), eq("cost-key"));
|
||||||
@@ -64,8 +65,35 @@ class PlatformFinanceControllerBehaviorTest {
|
|||||||
var request = new PlatformFinanceController.CostRequest(
|
var request = new PlatformFinanceController.CostRequest(
|
||||||
10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN,
|
10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN,
|
||||||
"CNY", List.of("91"), null);
|
"CNY", List.of("91"), null);
|
||||||
when(snapshotClient.container("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(
|
when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(
|
||||||
new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 10L, List.of())));
|
new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 99L, 10L, List.of())));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.cost(user, "cost-key", request))
|
||||||
|
.hasMessageContaining("未返回有效柜内订单体积快照");
|
||||||
|
verify(service, never()).createCost(any(Long.class), any(Long.class), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void containerCostPreservesFirstlegBusinessFailure() {
|
||||||
|
var request = new PlatformFinanceController.CostRequest(
|
||||||
|
10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN,
|
||||||
|
"CNY", List.of("91"), null);
|
||||||
|
when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(
|
||||||
|
new ApiResponse<>("A0404", "柜次不存在", null, "request", "trace", Instant.now()));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.cost(user, "cost-key", request))
|
||||||
|
.hasMessage("柜次不存在");
|
||||||
|
verify(service, never()).createCost(any(Long.class), any(Long.class), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void containerCostRejectsMismatchedLoadingPlanIdentity() {
|
||||||
|
var request = new PlatformFinanceController.CostRequest(
|
||||||
|
10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN,
|
||||||
|
"CNY", List.of("91"), null);
|
||||||
|
var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(89L, 99L, 10L, List.of(
|
||||||
|
new FirstlegCostSnapshotClient.OrderVolume(101L, 1001L, BigDecimal.ONE)));
|
||||||
|
when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot));
|
||||||
|
|
||||||
assertThatThrownBy(() -> controller.cost(user, "cost-key", request))
|
assertThatThrownBy(() -> controller.cost(user, "cost-key", request))
|
||||||
.hasMessageContaining("未返回有效柜内订单体积快照");
|
.hasMessageContaining("未返回有效柜内订单体积快照");
|
||||||
|
|||||||
+3
-2
@@ -13,6 +13,7 @@
|
|||||||
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
|
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
|
||||||
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
|
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
|
||||||
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java` |
|
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java` |
|
||||||
|
| 箱唛箱码生成与版本化 | `repository/InboundCartonLabelRepository.java`、`service/InboundCartonLabelService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` |
|
||||||
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
|
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
|
||||||
| 审核/自动完成 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`InboundCompletionOrchestrator.java` |
|
| 审核/自动完成 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`InboundCompletionOrchestrator.java` |
|
||||||
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
|
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
|
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
|
||||||
| 跨模型兼容桥 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
| 跨模型兼容桥 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
||||||
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java` |
|
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java` |
|
||||||
| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
|
| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
|
||||||
| 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` |
|
| 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` |
|
||||||
|
|
||||||
## 修改约束
|
## 修改约束
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
## 三期装柜与 PDA 资源结算
|
## 三期装柜与 PDA 资源结算
|
||||||
|
|
||||||
- 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
|
- 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
|
||||||
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;首次打印冻结箱码映射,重复打印和墨家测量复用同一箱码,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
||||||
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
|
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
|
||||||
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。
|
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。
|
||||||
- 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。
|
- 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ public final class InboundMeasurementResponses {
|
|||||||
Long measurementId,
|
Long measurementId,
|
||||||
String sourceType,
|
String sourceType,
|
||||||
String measurementStatus,
|
String measurementStatus,
|
||||||
|
@Schema(description = "入库主单乐观锁版本,用于测量审核、驳回和异常放行", example = "3")
|
||||||
|
Integer inboundVersion,
|
||||||
|
@Schema(description = "测量批次乐观锁版本,用于测量草稿和明细维护", example = "4")
|
||||||
Integer version,
|
Integer version,
|
||||||
String originalDocumentNo,
|
String originalDocumentNo,
|
||||||
BigDecimal originalGrossWeightKg,
|
BigDecimal originalGrossWeightKg,
|
||||||
|
|||||||
+4
-4
@@ -17,13 +17,13 @@ public class InternalFinanceCostSnapshotController {
|
|||||||
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
|
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
|
||||||
public InternalFinanceCostSnapshotController(FinanceCostSnapshotService service) { this.service = service; }
|
public InternalFinanceCostSnapshotController(FinanceCostSnapshotService service) { this.service = service; }
|
||||||
|
|
||||||
@GetMapping("/customers/{customerId}/containers/{containerId}")
|
@GetMapping("/customers/{customerId}/loading-plans/{loadingPlanId}")
|
||||||
public ApiResponse<FinanceCostSnapshotService.ContainerSnapshot> container(
|
public ApiResponse<FinanceCostSnapshotService.ContainerSnapshot> loadingPlan(
|
||||||
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
@PathVariable long customerId, @PathVariable long containerId) {
|
@PathVariable long customerId, @PathVariable long loadingPlanId) {
|
||||||
if (token == null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8))) {
|
if (token == null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8))) {
|
||||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
|
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
|
||||||
}
|
}
|
||||||
return ApiResponse.success(service.container(customerId, containerId));
|
return ApiResponse.success(service.loadingPlan(customerId, loadingPlanId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-9
@@ -13,27 +13,26 @@ public class FinanceCostSnapshotService {
|
|||||||
private final SmartLoadingRepository repository;
|
private final SmartLoadingRepository repository;
|
||||||
public FinanceCostSnapshotService(SmartLoadingRepository repository) { this.repository = repository; }
|
public FinanceCostSnapshotService(SmartLoadingRepository repository) { this.repository = repository; }
|
||||||
|
|
||||||
public ContainerSnapshot container(long customerId, long loadingPlanId) {
|
public ContainerSnapshot loadingPlan(long customerId, long loadingPlanId) {
|
||||||
if (customerId <= 0 || loadingPlanId <= 0) {
|
if (customerId <= 0 || loadingPlanId <= 0) {
|
||||||
throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
|
throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
|
||||||
}
|
}
|
||||||
// Older Finance deployments called this endpoint with the physical
|
|
||||||
// container booking ID. Resolve both identities during rollout while
|
|
||||||
// preserving the caller's ID in the snapshot contract.
|
|
||||||
var plan = repository.plan(loadingPlanId);
|
var plan = repository.plan(loadingPlanId);
|
||||||
if (plan == null) plan = repository.planByBooking(loadingPlanId);
|
|
||||||
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
|
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
|
||||||
long effectivePlanId = plan.getId() == null || plan.getId() <= 0 ? loadingPlanId : plan.getId();
|
if (plan.getContainerBookingId() == null || plan.getContainerBookingId() <= 0) {
|
||||||
List<OrderVolume> orders = repository.items(effectivePlanId).stream()
|
throw new BizException(ResultCode.BAD_REQUEST, "装柜计划未关联柜次");
|
||||||
|
}
|
||||||
|
List<OrderVolume> orders = repository.items(loadingPlanId).stream()
|
||||||
.filter(item -> item.getAccountId() != null && item.getAccountId() == customerId)
|
.filter(item -> item.getAccountId() != null && item.getAccountId() == customerId)
|
||||||
.filter(item -> item.getOrderId() != null && item.getOrderId() > 0)
|
.filter(item -> item.getOrderId() != null && item.getOrderId() > 0)
|
||||||
.filter(item -> item.getVolumeCbm() != null && item.getVolumeCbm().signum() > 0)
|
.filter(item -> item.getVolumeCbm() != null && item.getVolumeCbm().signum() > 0)
|
||||||
.map(item -> new OrderVolume(item.getOrderId(), item.getInboundId(), item.getVolumeCbm()))
|
.map(item -> new OrderVolume(item.getOrderId(), item.getInboundId(), item.getVolumeCbm()))
|
||||||
.toList();
|
.toList();
|
||||||
if (orders.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "柜次没有该客户的有效订单体积");
|
if (orders.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "柜次没有该客户的有效订单体积");
|
||||||
return new ContainerSnapshot(loadingPlanId, customerId, orders);
|
return new ContainerSnapshot(loadingPlanId, plan.getContainerBookingId(), customerId, orders);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ContainerSnapshot(long containerId, long customerId, List<OrderVolume> orders) {}
|
public record ContainerSnapshot(long loadingPlanId, long containerBookingId, long customerId,
|
||||||
|
List<OrderVolume> orders) {}
|
||||||
public record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {}
|
public record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {}
|
||||||
}
|
}
|
||||||
|
|||||||
-19
@@ -52,25 +52,6 @@ public class InboundCartonLabelRepository {
|
|||||||
.eq(InboundCartonLabelEntity::getBoxCode, boxCode));
|
.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) {
|
public void initializeLegacyFingerprint(long tenantId, long inboundId, int version, String fingerprint) {
|
||||||
labels.update(null, new LambdaUpdateWrapper<InboundCartonLabelEntity>()
|
labels.update(null, new LambdaUpdateWrapper<InboundCartonLabelEntity>()
|
||||||
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
|
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ public class ContainerService {
|
|||||||
if (containerNo == null) return;
|
if (containerNo == null) return;
|
||||||
LoadingPlanEntity plan = smartLoadingRepository.planByBooking(booking.getId());
|
LoadingPlanEntity plan = smartLoadingRepository.planByBooking(booking.getId());
|
||||||
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return;
|
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return;
|
||||||
|
// The PDA task and its item versions are stable work identities. A later transport-number
|
||||||
|
// correction only changes the booking display value and must not resynchronize the task.
|
||||||
|
if (loadingPickRepository.taskByPlan(plan.getId()) != null) return;
|
||||||
loadingPickService.begin(plan.getId(), containerNo, operatorId, operatorName,
|
loadingPickService.begin(plan.getId(), containerNo, operatorId, operatorName,
|
||||||
smartLoadingRepository.items(plan.getId()));
|
smartLoadingRepository.items(plan.getId()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
|||||||
return batch(inbound, consistent);
|
return batch(inbound, consistent);
|
||||||
}
|
}
|
||||||
if (existingFingerprint.equals(fingerprint)) return batch(inbound, consistent);
|
if (existingFingerprint.equals(fingerprint)) return batch(inbound, consistent);
|
||||||
repository.voidActive(tenantId, inboundId, LocalDateTime.now());
|
return batch(inbound, consistent);
|
||||||
existing.clear();
|
|
||||||
}
|
}
|
||||||
int total = cargoes.stream().mapToInt(this::requirePackageCount).sum();
|
int total = cargoes.stream().mapToInt(this::requirePackageCount).sum();
|
||||||
if (total < 1 || total > MAX_LABELS) {
|
if (total < 1 || total > MAX_LABELS) {
|
||||||
@@ -68,8 +67,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
|||||||
}
|
}
|
||||||
repository.updatePackageCount(tenantId, inboundId, total);
|
repository.updatePackageCount(tenantId, inboundId, total);
|
||||||
inbound.setTotalPackageCount(total);
|
inbound.setTotalPackageCount(total);
|
||||||
int labelVersion = repository.nextVersion(tenantId, inboundId);
|
requirePrintableInboundCode(inbound.getInboundCode(), total);
|
||||||
requirePrintableInboundCode(inbound.getInboundCode(), total, labelVersion);
|
|
||||||
|
|
||||||
int cartonIndex = 0;
|
int cartonIndex = 0;
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
@@ -77,7 +75,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
|||||||
int detailCount = requirePackageCount(cargo);
|
int detailCount = requirePackageCount(cargo);
|
||||||
for (int offset = 0; offset < detailCount; offset++) {
|
for (int offset = 0; offset < detailCount; offset++) {
|
||||||
cartonIndex++;
|
cartonIndex++;
|
||||||
InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, labelVersion, fingerprint, now);
|
InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, fingerprint, now);
|
||||||
repository.insert(label);
|
repository.insert(label);
|
||||||
existing.add(label);
|
existing.add(label);
|
||||||
}
|
}
|
||||||
@@ -148,7 +146,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
|||||||
InboundCartonLabelEntity label = repository.findByBoxCode(tenantId, boxCode.trim());
|
InboundCartonLabelEntity label = repository.findByBoxCode(tenantId, boxCode.trim());
|
||||||
if (label == null) throw new BizException(ResultCode.NOT_FOUND, "箱号不存在");
|
if (label == null) throw new BizException(ResultCode.NOT_FOUND, "箱号不存在");
|
||||||
if ("VOID".equals(label.getBatchStatus())) {
|
if ("VOID".equals(label.getBatchStatus())) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "箱唛版本已作废,请重新打印");
|
throw new BizException(ResultCode.CONFLICT, "箱码已失效,请重新打印");
|
||||||
}
|
}
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
@@ -182,11 +180,11 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
|||||||
return cargo.packageCount();
|
return cargo.packageCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requirePrintableInboundCode(String inboundCode, int total, int version) {
|
private void requirePrintableInboundCode(String inboundCode, int total) {
|
||||||
if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) {
|
if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) {
|
||||||
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码不能生成可打印二维码");
|
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码不能生成可打印二维码");
|
||||||
}
|
}
|
||||||
String longest = inboundCode + "#V" + version + "#" + total + "-" + total;
|
String longest = inboundCode + "#" + total + "-" + total;
|
||||||
if (longest.codePointCount(0, longest.length()) > MAX_QR_CODE_POINTS) {
|
if (longest.codePointCount(0, longest.length()) > MAX_QR_CODE_POINTS) {
|
||||||
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码和箱号组合超过二维码长度限制");
|
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码和箱号组合超过二维码长度限制");
|
||||||
}
|
}
|
||||||
@@ -194,7 +192,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
|||||||
|
|
||||||
private InboundCartonLabelEntity label(InboundOrderEntity inbound,
|
private InboundCartonLabelEntity label(InboundOrderEntity inbound,
|
||||||
OrderCargoClient.CargoLabelBinding cargo,
|
OrderCargoClient.CargoLabelBinding cargo,
|
||||||
int total, int index, int labelVersion, String fingerprint,
|
int total, int index, String fingerprint,
|
||||||
LocalDateTime now) {
|
LocalDateTime now) {
|
||||||
InboundCartonLabelEntity label = new InboundCartonLabelEntity();
|
InboundCartonLabelEntity label = new InboundCartonLabelEntity();
|
||||||
label.setId(IdWorker.getId());
|
label.setId(IdWorker.getId());
|
||||||
@@ -206,10 +204,10 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
|||||||
label.setCargoName(cargo.cargoName());
|
label.setCargoName(cargo.cargoName());
|
||||||
label.setDetailPackageCount(cargo.packageCount());
|
label.setDetailPackageCount(cargo.packageCount());
|
||||||
label.setInboundCode(inbound.getInboundCode());
|
label.setInboundCode(inbound.getInboundCode());
|
||||||
label.setBoxCode(inbound.getInboundCode() + "#V" + labelVersion + "#" + total + "-" + index);
|
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
|
||||||
label.setTotalCartonCount(total);
|
label.setTotalCartonCount(total);
|
||||||
label.setCartonIndex(index);
|
label.setCartonIndex(index);
|
||||||
label.setLabelVersion(labelVersion);
|
label.setLabelVersion(1);
|
||||||
label.setBatchStatus("ACTIVE");
|
label.setBatchStatus("ACTIVE");
|
||||||
label.setCargoFingerprint(fingerprint);
|
label.setCargoFingerprint(fingerprint);
|
||||||
label.setLengthCm(cargo.lengthCm());
|
label.setLengthCm(cargo.lengthCm());
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ public class InboundMeasurementService {
|
|||||||
review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(),
|
review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(),
|
||||||
measurement == null ? null : measurement.getSourceType(),
|
measurement == null ? null : measurement.getSourceType(),
|
||||||
measurement == null ? null : measurement.getStatus(),
|
measurement == null ? null : measurement.getStatus(),
|
||||||
|
inbound.getVersion(),
|
||||||
measurement == null ? null : measurement.getVersion(),
|
measurement == null ? null : measurement.getVersion(),
|
||||||
measurement == null ? null : measurement.getOriginalDocumentNo(),
|
measurement == null ? null : measurement.getOriginalDocumentNo(),
|
||||||
measurement == null ? null : measurement.getOriginalGrossWeightKg(),
|
measurement == null ? null : measurement.getOriginalGrossWeightKg(),
|
||||||
|
|||||||
@@ -423,8 +423,21 @@ public class LoadingPickService {
|
|||||||
if (item.getPickedPackageCount() > planItem.getPackageCount()) {
|
if (item.getPickedPackageCount() > planItem.getPackageCount()) {
|
||||||
throw conflict("已拣货箱数不能低于计划箱数");
|
throw conflict("已拣货箱数不能低于计划箱数");
|
||||||
}
|
}
|
||||||
|
int pickedPackageCount = count(item.getPickedPackageCount());
|
||||||
|
String itemStatus = pickedPackageCount == 0 ? "PENDING"
|
||||||
|
: pickedPackageCount == planItem.getPackageCount() ? "PICKED" : "PICKING";
|
||||||
|
if (Objects.equals(item.getPickTaskId(), taskId)
|
||||||
|
&& Objects.equals(item.getInboundId(), planItem.getInboundId())
|
||||||
|
&& Objects.equals(item.getInboundDetailId(), planItem.getInboundDetailId())
|
||||||
|
&& Objects.equals(item.getPlannedPackageCount(), planItem.getPackageCount())
|
||||||
|
&& Objects.equals(item.getItemStatus(), itemStatus)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
item.setPickTaskId(taskId);
|
||||||
|
item.setInboundId(planItem.getInboundId());
|
||||||
|
item.setInboundDetailId(planItem.getInboundDetailId());
|
||||||
item.setPlannedPackageCount(planItem.getPackageCount());
|
item.setPlannedPackageCount(planItem.getPackageCount());
|
||||||
item.setItemStatus(item.getPickedPackageCount().equals(planItem.getPackageCount()) ? "PICKED" : "PICKING");
|
item.setItemStatus(itemStatus);
|
||||||
repository.update(item);
|
repository.update(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END$$
|
END$$
|
||||||
DELIMITER ;
|
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('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('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('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');
|
CALL phase2_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint');
|
||||||
@@ -23,9 +23,12 @@ UPDATE fl_inbound_carton_label
|
|||||||
batch_status = 'ACTIVE'
|
batch_status = 'ACTIVE'
|
||||||
WHERE label_version IS NULL OR batch_status IS NULL;
|
WHERE label_version IS NULL OR batch_status IS NULL;
|
||||||
|
|
||||||
|
DELETE FROM fl_inbound_carton_label WHERE batch_status = 'VOID';
|
||||||
|
|
||||||
UPDATE fl_inbound_carton_label
|
UPDATE fl_inbound_carton_label
|
||||||
SET box_code = CONCAT(inbound_code, '#V', label_version, '#', total_carton_count, '-', carton_index)
|
SET label_version = 1,
|
||||||
WHERE box_code NOT LIKE CONCAT(inbound_code, '#V%#%');
|
box_code = CONCAT(inbound_code, '#', total_carton_count, '-', carton_index)
|
||||||
|
WHERE batch_status = 'ACTIVE';
|
||||||
|
|
||||||
SET @has_old_index := (SELECT COUNT(*) FROM information_schema.statistics
|
SET @has_old_index := (SELECT COUNT(*) FROM information_schema.statistics
|
||||||
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_carton_label'
|
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_carton_label'
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
|||||||
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
||||||
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
|
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
|
||||||
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
|
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
|
||||||
label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '箱唛批次版本,从1递增',
|
label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '内部兼容批次字段,固定为1且不进入箱码',
|
||||||
batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废',
|
batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废',
|
||||||
cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹',
|
cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹',
|
||||||
voided_at DATETIME(3) NULL COMMENT '批次作废时间',
|
voided_at DATETIME(3) NULL COMMENT '批次作废时间',
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
|||||||
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
||||||
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
|
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
|
||||||
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
|
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
|
||||||
label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '箱唛批次版本,从1递增',
|
label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '内部兼容批次字段,固定为1且不进入箱码',
|
||||||
batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废',
|
batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废',
|
||||||
cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹',
|
cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹',
|
||||||
voided_at DATETIME(3) NULL COMMENT '批次作废时间',
|
voided_at DATETIME(3) NULL COMMENT '批次作废时间',
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ components:
|
|||||||
loadingPlanId: {type: integer, format: int64}
|
loadingPlanId: {type: integer, format: int64}
|
||||||
containerBookingId: {type: integer, format: int64}
|
containerBookingId: {type: integer, format: int64}
|
||||||
cabinetNo: {type: string}
|
cabinetNo: {type: string}
|
||||||
currentContainerNo: {type: string, nullable: true, description: Mutable transport display field; never task identity}
|
currentContainerNo: {type: string, nullable: true, description: Mutable transport display field; changing it never resynchronizes the task or item versions and it is never task identity}
|
||||||
taskStatus: {type: string, enum: [LOADING, COMPLETED]}
|
taskStatus: {type: string, enum: [LOADING, COMPLETED]}
|
||||||
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, COMPLETED]}
|
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, COMPLETED]}
|
||||||
version: {type: integer, minimum: 0}
|
version: {type: integer, minimum: 0}
|
||||||
|
|||||||
+1
@@ -28,6 +28,7 @@ class PdaConvergenceOpenApiContractTest {
|
|||||||
.contains("BatchRequest")
|
.contains("BatchRequest")
|
||||||
.contains("inventorySnapshotToken")
|
.contains("inventorySnapshotToken")
|
||||||
.contains("InventorySnapshot")
|
.contains("InventorySnapshot")
|
||||||
|
.contains("changing it never resynchronizes the task or item versions")
|
||||||
.contains("expectedTaskVersion")
|
.contains("expectedTaskVersion")
|
||||||
.contains("CompleteLoadingRequest");
|
.contains("CompleteLoadingRequest");
|
||||||
}
|
}
|
||||||
|
|||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package com.baiyue.firstleg.loading.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.firstleg.loading.service.FinanceCostSnapshotService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
class InternalFinanceCostSnapshotControllerTest {
|
||||||
|
private final FinanceCostSnapshotService service = mock(FinanceCostSnapshotService.class);
|
||||||
|
private final InternalFinanceCostSnapshotController controller = controller();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requiresInternalTokenAndUsesStableLoadingPlanIdentity() {
|
||||||
|
assertThatThrownBy(() -> controller.loadingPlan("wrong", 10L, 88L))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
|
||||||
|
|
||||||
|
controller.loadingPlan("finance-token", 10L, 88L);
|
||||||
|
|
||||||
|
verify(service).loadingPlan(10L, 88L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exposesLoadingPlanSnapshotPath() throws Exception {
|
||||||
|
var mapping = InternalFinanceCostSnapshotController.class
|
||||||
|
.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
|
||||||
|
assertThat(mapping.value()).containsExactly("/internal/firstleg/v1/finance-cost-snapshots");
|
||||||
|
var method = InternalFinanceCostSnapshotController.class.getDeclaredMethod(
|
||||||
|
"loadingPlan", String.class, long.class, long.class);
|
||||||
|
assertThat(method.getAnnotation(GetMapping.class).value())
|
||||||
|
.containsExactly("/customers/{customerId}/loading-plans/{loadingPlanId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private InternalFinanceCostSnapshotController controller() {
|
||||||
|
var value = new InternalFinanceCostSnapshotController(service);
|
||||||
|
ReflectionTestUtils.setField(value, "expectedToken", "finance-token");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-15
@@ -24,28 +24,34 @@ class FinanceCostSnapshotServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void returnsOnlyValidVolumesOwnedByCustomer() {
|
void returnsOnlyValidVolumesOwnedByCustomer() {
|
||||||
when(repository.plan(88L)).thenReturn(mock(LoadingPlanEntity.class));
|
var plan = new LoadingPlanEntity();
|
||||||
|
plan.setId(88L);
|
||||||
|
plan.setContainerBookingId(99L);
|
||||||
|
when(repository.plan(88L)).thenReturn(plan);
|
||||||
when(repository.items(88L)).thenReturn(List.of(
|
when(repository.items(88L)).thenReturn(List.of(
|
||||||
item(10L, 101L, 1001L, "1.25"),
|
item(10L, 101L, 1001L, "1.25"),
|
||||||
item(11L, 102L, 1002L, "2.00"),
|
item(11L, 102L, 1002L, "2.00"),
|
||||||
item(10L, 103L, 1003L, "0")));
|
item(10L, 103L, 1003L, "0")));
|
||||||
|
|
||||||
var snapshot = service.container(10L, 88L);
|
var snapshot = service.loadingPlan(10L, 88L);
|
||||||
|
|
||||||
assertThat(snapshot.customerId()).isEqualTo(10L);
|
assertThat(snapshot.customerId()).isEqualTo(10L);
|
||||||
assertThat(snapshot.containerId()).isEqualTo(88L);
|
assertThat(snapshot.loadingPlanId()).isEqualTo(88L);
|
||||||
|
assertThat(snapshot.containerBookingId()).isEqualTo(99L);
|
||||||
assertThat(snapshot.orders()).containsExactly(
|
assertThat(snapshot.orders()).containsExactly(
|
||||||
new FinanceCostSnapshotService.OrderVolume(101L, 1001L, new BigDecimal("1.25")));
|
new FinanceCostSnapshotService.OrderVolume(101L, 1001L, new BigDecimal("1.25")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsContainerWithoutValidCustomerVolume() {
|
void rejectsContainerWithoutValidCustomerVolume() {
|
||||||
when(repository.plan(88L)).thenReturn(mock(LoadingPlanEntity.class));
|
var plan = new LoadingPlanEntity();
|
||||||
|
plan.setContainerBookingId(99L);
|
||||||
|
when(repository.plan(88L)).thenReturn(plan);
|
||||||
when(repository.items(88L)).thenReturn(List.of(
|
when(repository.items(88L)).thenReturn(List.of(
|
||||||
item(11L, 102L, 1002L, "2.00"),
|
item(11L, 102L, 1002L, "2.00"),
|
||||||
item(10L, 103L, 1003L, "0")));
|
item(10L, 103L, 1003L, "0")));
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.container(10L, 88L))
|
assertThatThrownBy(() -> service.loadingPlan(10L, 88L))
|
||||||
.hasMessageContaining("没有该客户的有效订单体积");
|
.hasMessageContaining("没有该客户的有效订单体积");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,23 +59,18 @@ class FinanceCostSnapshotServiceTest {
|
|||||||
void rejectsUnknownContainer() {
|
void rejectsUnknownContainer() {
|
||||||
when(repository.plan(88L)).thenReturn(null);
|
when(repository.plan(88L)).thenReturn(null);
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.container(10L, 88L))
|
assertThatThrownBy(() -> service.loadingPlan(10L, 88L))
|
||||||
.hasMessageContaining("柜次不存在");
|
.hasMessageContaining("柜次不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void resolvesLegacyContainerBookingIdToLoadingPlan() {
|
void rejectsPlanWithoutContainerBooking() {
|
||||||
var plan = new LoadingPlanEntity();
|
var plan = new LoadingPlanEntity();
|
||||||
plan.setId(88L);
|
plan.setId(88L);
|
||||||
plan.setContainerBookingId(99L);
|
when(repository.plan(88L)).thenReturn(plan);
|
||||||
when(repository.plan(99L)).thenReturn(null);
|
|
||||||
when(repository.planByBooking(99L)).thenReturn(plan);
|
|
||||||
when(repository.items(88L)).thenReturn(List.of(item(10L, 101L, 1001L, "1.25")));
|
|
||||||
|
|
||||||
var snapshot = service.container(10L, 99L);
|
assertThatThrownBy(() -> service.loadingPlan(10L, 88L))
|
||||||
|
.hasMessageContaining("未关联柜次");
|
||||||
assertThat(snapshot.containerId()).isEqualTo(99L);
|
|
||||||
assertThat(snapshot.orders()).hasSize(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private LoadingPlanItemEntity item(long customerId, long orderId, long inboundId, String volume) {
|
private LoadingPlanItemEntity item(long customerId, long orderId, long inboundId, String volume) {
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ class InboundCartonLabelSqlContractTest {
|
|||||||
.contains("uk_carton_label_box_code")
|
.contains("uk_carton_label_box_code")
|
||||||
.contains("uk_carton_label_index")
|
.contains("uk_carton_label_index")
|
||||||
.contains("稳定箱号映射表未创建")
|
.contains("稳定箱号映射表未创建")
|
||||||
.contains("箱唛版本化字段未完整创建")
|
.contains("稳定箱码映射兼容字段未完整创建")
|
||||||
.contains("箱唛有效批次索引未完整创建");
|
.contains("箱唛有效批次索引未完整创建");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,27 @@ class ContainerServiceTest {
|
|||||||
org.mockito.ArgumentMatchers.eq("admin"), any());
|
org.mockito.ArgumentMatchers.eq("admin"), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("PDA任务创建后替换真实柜号不重同步任务明细")
|
||||||
|
void doesNotResynchronizeExistingPdaTaskWhenContainerNumberChanges() {
|
||||||
|
ContainerBookingEntity current = booking("EDITING", 2);
|
||||||
|
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||||
|
plan.setId(31L);
|
||||||
|
plan.setPlanStatus("EDITING");
|
||||||
|
plan.setPreloadPushedAt(LocalDateTime.of(2026, 8, 11, 20, 0));
|
||||||
|
when(repository.find(11L)).thenReturn(current);
|
||||||
|
when(repository.updateTransport(11L, "MSCU7654321", "V002", 2, 9L, "admin"))
|
||||||
|
.thenReturn(1);
|
||||||
|
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
|
||||||
|
when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity());
|
||||||
|
|
||||||
|
service.updateTransport(11L, 9L, "admin",
|
||||||
|
new TransportUpdateRequest("MSCU7654321", "V002", 2));
|
||||||
|
|
||||||
|
verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), any(), any());
|
||||||
|
verify(smartLoadingRepository, never()).items(31L);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("PDA任务创建后禁止清空真实集装箱号")
|
@DisplayName("PDA任务创建后禁止清空真实集装箱号")
|
||||||
void rejectsClearingContainerNumberAfterPdaTaskStarts() {
|
void rejectsClearingContainerNumberAfterPdaTaskStarts() {
|
||||||
|
|||||||
+22
-29
@@ -50,7 +50,6 @@ class InboundCartonLabelServiceTest {
|
|||||||
var first = cargo(80001L, 1, 3, "60", "40", "30");
|
var first = cargo(80001L, 1, 3, "60", "40", "30");
|
||||||
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
||||||
.thenReturn(ApiResponse.success(List.of(second, first)));
|
.thenReturn(ApiResponse.success(List.of(second, first)));
|
||||||
when(repository.nextVersion(10001L, 70001L)).thenReturn(1);
|
|
||||||
List<InboundCartonLabelEntity> inserted = new ArrayList<>();
|
List<InboundCartonLabelEntity> inserted = new ArrayList<>();
|
||||||
org.mockito.Mockito.doAnswer(invocation -> {
|
org.mockito.Mockito.doAnswer(invocation -> {
|
||||||
inserted.add(invocation.getArgument(0));
|
inserted.add(invocation.getArgument(0));
|
||||||
@@ -61,8 +60,8 @@ class InboundCartonLabelServiceTest {
|
|||||||
|
|
||||||
assertThat(batch.totalCount()).isEqualTo(5);
|
assertThat(batch.totalCount()).isEqualTo(5);
|
||||||
assertThat(batch.labels()).extracting(label -> label.boxCode())
|
assertThat(batch.labels()).extracting(label -> label.boxCode())
|
||||||
.containsExactly("PCHK2608040174#V1#5-1", "PCHK2608040174#V1#5-2", "PCHK2608040174#V1#5-3",
|
.containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3",
|
||||||
"PCHK2608040174#V1#5-4", "PCHK2608040174#V1#5-5");
|
"PCHK2608040174#5-4", "PCHK2608040174#5-5");
|
||||||
assertThat(batch.labels()).extracting(label -> label.inboundDetailId())
|
assertThat(batch.labels()).extracting(label -> label.inboundDetailId())
|
||||||
.containsExactly(80001L, 80001L, 80001L, 80002L, 80002L);
|
.containsExactly(80001L, 80001L, 80001L, 80002L, 80002L);
|
||||||
assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm");
|
assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm");
|
||||||
@@ -73,7 +72,7 @@ class InboundCartonLabelServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void repeatedAndPartialPrintsReadCurrentVersionWithoutReallocation() {
|
void repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation() {
|
||||||
InboundOrderEntity inbound = inbound();
|
InboundOrderEntity inbound = inbound();
|
||||||
inbound.setTotalPackageCount(5);
|
inbound.setTotalPackageCount(5);
|
||||||
List<InboundCartonLabelEntity> existing = labels(inbound, 5);
|
List<InboundCartonLabelEntity> existing = labels(inbound, 5);
|
||||||
@@ -92,7 +91,7 @@ class InboundCartonLabelServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void changedCargoCreatesNewVersion() {
|
void changedCargoKeepsTheFirstFrozenBoxCodes() {
|
||||||
InboundOrderEntity inbound = inbound();
|
InboundOrderEntity inbound = inbound();
|
||||||
inbound.setTotalPackageCount(4);
|
inbound.setTotalPackageCount(4);
|
||||||
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
|
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
|
||||||
@@ -101,13 +100,11 @@ class InboundCartonLabelServiceTest {
|
|||||||
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing);
|
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing);
|
||||||
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
||||||
.thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30"))));
|
.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);
|
var result = service.getOrCreate(10001L, 70001L);
|
||||||
assertThat(result.labels()).extracting(label -> label.boxCode())
|
assertThat(result.labels()).extracting(label -> label.boxCode())
|
||||||
.containsExactly("PCHK2608040174#V2#2-1", "PCHK2608040174#V2#2-2");
|
.containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3",
|
||||||
verify(repository).voidActive(org.mockito.ArgumentMatchers.eq(10001L),
|
"PCHK2608040174#5-4", "PCHK2608040174#5-5");
|
||||||
org.mockito.ArgumentMatchers.eq(70001L), any());
|
verify(repository, never()).insert(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -119,14 +116,13 @@ class InboundCartonLabelServiceTest {
|
|||||||
when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
|
when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
|
||||||
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
||||||
.thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30"))));
|
.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());
|
org.mockito.Mockito.doAnswer(invocation -> null).when(repository).insert(any());
|
||||||
|
|
||||||
var result = service.getOrCreate(10001L, 70001L);
|
var result = service.getOrCreate(10001L, 70001L);
|
||||||
|
|
||||||
assertThat(result.totalCount()).isEqualTo(2);
|
assertThat(result.totalCount()).isEqualTo(2);
|
||||||
assertThat(result.labels()).extracting(label -> label.boxCode())
|
assertThat(result.labels()).extracting(label -> label.boxCode())
|
||||||
.containsExactly("PCHK2608040174#V1#2-1", "PCHK2608040174#V1#2-2");
|
.containsExactly("PCHK2608040174#2-1", "PCHK2608040174#2-2");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -143,7 +139,7 @@ class InboundCartonLabelServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void changedCargoAfterLoadingVoidsCurrentBatchAndCreatesNextVersion() {
|
void changedCargoAfterLoadingKeepsTheFirstFrozenBoxCodes() {
|
||||||
InboundOrderEntity inbound = inbound();
|
InboundOrderEntity inbound = inbound();
|
||||||
inbound.setProcessStatus("LOADING");
|
inbound.setProcessStatus("LOADING");
|
||||||
List<InboundCartonLabelEntity> existing = labels(inbound, 1);
|
List<InboundCartonLabelEntity> existing = labels(inbound, 1);
|
||||||
@@ -155,22 +151,19 @@ class InboundCartonLabelServiceTest {
|
|||||||
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing);
|
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing);
|
||||||
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
||||||
.thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30"))));
|
.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);
|
var result = service.getOrCreate(10001L, 70001L);
|
||||||
|
|
||||||
assertThat(result.labels()).extracting(label -> label.boxCode())
|
assertThat(result.labels()).extracting(label -> label.boxCode())
|
||||||
.containsExactly("PCHK2608040174#V2#2-1", "PCHK2608040174#V2#2-2");
|
.containsExactly("PCHK2608040174#1-1");
|
||||||
verify(repository).voidActive(org.mockito.ArgumentMatchers.eq(10001L),
|
verify(repository, never()).insert(any());
|
||||||
org.mockito.ArgumentMatchers.eq(70001L), any());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void resolvesBoxCodeInsideCustomerBoundary() {
|
void resolvesBoxCodeInsideCustomerBoundary() {
|
||||||
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
|
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
|
||||||
when(repository.findByBoxCode(10001L, "PCHK2608040174#V1#1-1")).thenReturn(label);
|
when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label);
|
||||||
|
|
||||||
var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#V1#1-1 ");
|
var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#1-1 ");
|
||||||
|
|
||||||
assertThat(resolved.inboundId()).isEqualTo(70001L);
|
assertThat(resolved.inboundId()).isEqualTo(70001L);
|
||||||
assertThat(resolved.inboundDetailId()).isEqualTo(80001L);
|
assertThat(resolved.inboundDetailId()).isEqualTo(80001L);
|
||||||
@@ -178,30 +171,30 @@ class InboundCartonLabelServiceTest {
|
|||||||
assertThat(resolved.cartonIndex()).isEqualTo(1);
|
assertThat(resolved.cartonIndex()).isEqualTo(1);
|
||||||
assertThat(resolved.totalCartons()).isEqualTo(1);
|
assertThat(resolved.totalCartons()).isEqualTo(1);
|
||||||
assertThat(resolved.boxSpecification()).isEqualTo("60x40x30cm");
|
assertThat(resolved.boxSpecification()).isEqualTo("60x40x30cm");
|
||||||
verify(repository).findByBoxCode(10001L, "PCHK2608040174#V1#1-1");
|
verify(repository).findByBoxCode(10001L, "PCHK2608040174#1-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void exposesDetailedResolutionForFirstlegConsumers() {
|
void exposesDetailedResolutionForFirstlegConsumers() {
|
||||||
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
|
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
|
||||||
when(repository.findByBoxCode(10001L, "PCHK2608040174#V1#1-1")).thenReturn(label);
|
when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label);
|
||||||
|
|
||||||
var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#V1#1-1");
|
var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#1-1");
|
||||||
|
|
||||||
assertThat(resolved.orderId()).isEqualTo(60001L);
|
assertThat(resolved.orderId()).isEqualTo(60001L);
|
||||||
assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#V1#1-1");
|
assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#1-1");
|
||||||
assertThat(resolved.cargoItemNo()).isEqualTo(1);
|
assertThat(resolved.cargoItemNo()).isEqualTo(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsVoidedLabelVersionDuringScanResolution() {
|
void rejectsInactiveBoxCodeDuringScanResolution() {
|
||||||
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
|
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
|
||||||
label.setBatchStatus("VOID");
|
label.setBatchStatus("VOID");
|
||||||
when(repository.findByBoxCode(10001L, "PCHK2608040174#V1#1-1")).thenReturn(label);
|
when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label);
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608040174#V1#1-1"))
|
assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608040174#1-1"))
|
||||||
.isInstanceOf(BizException.class)
|
.isInstanceOf(BizException.class)
|
||||||
.hasMessage("箱唛版本已作废,请重新打印");
|
.hasMessage("箱码已失效,请重新打印");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -284,7 +277,7 @@ class InboundCartonLabelServiceTest {
|
|||||||
label.setCargoName("货物1");
|
label.setCargoName("货物1");
|
||||||
label.setDetailPackageCount(total);
|
label.setDetailPackageCount(total);
|
||||||
label.setInboundCode(inbound.getInboundCode());
|
label.setInboundCode(inbound.getInboundCode());
|
||||||
label.setBoxCode(inbound.getInboundCode() + "#V1#" + total + "-" + index);
|
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
|
||||||
label.setTotalCartonCount(total);
|
label.setTotalCartonCount(total);
|
||||||
label.setCartonIndex(index);
|
label.setCartonIndex(index);
|
||||||
label.setLabelVersion(1);
|
label.setLabelVersion(1);
|
||||||
|
|||||||
+9
-1
@@ -87,12 +87,20 @@ class InboundMeasurementServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void detailReturnsStandardPalletRecommendationWithoutAllocatingPallet() {
|
void detailReturnsStandardPalletRecommendationWithoutAllocatingPallet() {
|
||||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound("MEASURE_PENDING"));
|
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
|
||||||
|
inbound.setVersion(7);
|
||||||
|
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||||
|
measurement.setId(30L);
|
||||||
|
measurement.setVersion(4);
|
||||||
|
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||||
|
when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement);
|
||||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
|
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
|
||||||
|
|
||||||
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
|
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
|
||||||
|
|
||||||
assertThat(result.recommendedPalletType()).isEqualTo("STANDARD");
|
assertThat(result.recommendedPalletType()).isEqualTo("STANDARD");
|
||||||
|
assertThat(result.inboundVersion()).isEqualTo(7);
|
||||||
|
assertThat(result.version()).isEqualTo(4);
|
||||||
verify(repository, never()).updateInbound(any());
|
verify(repository, never()).updateInbound(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
|||||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||||
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
|
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
|
||||||
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
||||||
|
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
||||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
@@ -352,6 +353,69 @@ class LoadingPickServiceTest {
|
|||||||
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
|
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void staleTaskVersionDoesNotDriftItemAndFollowingValidBatchSucceeds() {
|
||||||
|
LoadingPickTaskEntity task = task();
|
||||||
|
LoadingPickItemEntity item = item();
|
||||||
|
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||||
|
when(repository.task(20L)).thenReturn(task);
|
||||||
|
when(repository.assignment(20L)).thenReturn(assignment());
|
||||||
|
when(repository.items(20L)).thenReturn(List.of(item));
|
||||||
|
when(repository.items(20L, 101L)).thenReturn(List.of(item));
|
||||||
|
when(repository.pick(30L, 2, "MANUAL", "[]", 0, 101L)).thenReturn(1);
|
||||||
|
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||||
|
when(repository.touchTask(20L, 0)).thenReturn(1);
|
||||||
|
when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn(
|
||||||
|
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource(
|
||||||
|
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2))));
|
||||||
|
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
|
||||||
|
BatchItemRequest line = new BatchItemRequest(
|
||||||
|
30L, "PICK", 2, null, "MANUAL", List.of(), 0, null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.batch(20L, "batch-stale", new BatchRequest(
|
||||||
|
101L, 10L, 99L, "CSLU1234567", 1, snapshotToken, List.of(line)), 9L, "operator"))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("任务版本已变化");
|
||||||
|
|
||||||
|
assertThat(item.getVersion()).isZero();
|
||||||
|
assertThat(item.getPickedPackageCount()).isZero();
|
||||||
|
service.batch(20L, "batch-valid", new BatchRequest(
|
||||||
|
101L, 10L, 99L, "CSLU1234567", 0, snapshotToken, List.of(line)), 9L, "operator");
|
||||||
|
|
||||||
|
verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L);
|
||||||
|
verify(repository, times(1)).touchTask(20L, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void repeatedTaskBeginDoesNotChangePendingItemBeforeRejectedStaleBatch() {
|
||||||
|
LoadingPickTaskEntity task = task();
|
||||||
|
LoadingPickItemEntity pending = item();
|
||||||
|
var planItem = new LoadingPlanItemEntity();
|
||||||
|
planItem.setId(40L);
|
||||||
|
planItem.setInboundId(50L);
|
||||||
|
planItem.setInboundDetailId(60L);
|
||||||
|
planItem.setPackageCount(2);
|
||||||
|
when(repository.taskByPlan(10L)).thenReturn(task);
|
||||||
|
when(repository.itemByPlanItem(40L)).thenReturn(pending);
|
||||||
|
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||||
|
|
||||||
|
service.begin(10L, "CSLU1234567", 9L, "operator", List.of(planItem));
|
||||||
|
|
||||||
|
BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 1,
|
||||||
|
"stale-token", List.of(
|
||||||
|
new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null)));
|
||||||
|
assertThatThrownBy(() -> service.batch(20L, "batch-stale-after-resync", staleRequest,
|
||||||
|
9L, "operator")).isInstanceOf(BizException.class);
|
||||||
|
|
||||||
|
assertThat(pending.getVersion()).isZero();
|
||||||
|
assertThat(pending.getPickedPackageCount()).isZero();
|
||||||
|
assertThat(pending.getItemStatus()).isEqualTo("PENDING");
|
||||||
|
verify(repository, never()).update(any(LoadingPickItemEntity.class));
|
||||||
|
verify(repository, never()).pick(any(Long.class), any(Integer.class), any(), any(),
|
||||||
|
any(Integer.class), any(Long.class));
|
||||||
|
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
|
||||||
|
}
|
||||||
|
|
||||||
private void preparePick(LoadingPickTaskEntity task, LoadingPickItemEntity item, String operationId) {
|
private void preparePick(LoadingPickTaskEntity task, LoadingPickItemEntity item, String operationId) {
|
||||||
when(repository.history(operationId)).thenReturn(null);
|
when(repository.history(operationId)).thenReturn(null);
|
||||||
when(repository.task(20L)).thenReturn(task);
|
when(repository.task(20L)).thenReturn(task);
|
||||||
|
|||||||
@@ -12,7 +12,19 @@ import process from 'node:process'
|
|||||||
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
|
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
|
||||||
const DEFAULT_USERNAME = 'platform_admin'
|
const DEFAULT_USERNAME = 'platform_admin'
|
||||||
const DEFAULT_PASSWORD = 'ChangeMe123!'
|
const DEFAULT_PASSWORD = 'ChangeMe123!'
|
||||||
const DEFAULT_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'customer-operating-statistics']
|
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'customer-operating-statistics']
|
||||||
|
|
||||||
|
export function normalizeModuleOrder(modules) {
|
||||||
|
const normalized = [...modules]
|
||||||
|
const loadingIndex = normalized.indexOf('phase3-loading')
|
||||||
|
const overviewIndex = normalized.indexOf('phase3-container-overview')
|
||||||
|
if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized
|
||||||
|
normalized.splice(overviewIndex, 1)
|
||||||
|
normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview')
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES)
|
||||||
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
|
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
|
||||||
const CUSTOMER_FIELDS = [
|
const CUSTOMER_FIELDS = [
|
||||||
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
|
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
|
||||||
@@ -47,7 +59,7 @@ function usage() {
|
|||||||
环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`)
|
环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArguments(argv) {
|
export function parseArguments(argv) {
|
||||||
if (argv.includes('--help')) { usage(); process.exit(0) }
|
if (argv.includes('--help')) { usage(); process.exit(0) }
|
||||||
const values = new Map()
|
const values = new Map()
|
||||||
for (let index = 0; index < argv.length; index += 1) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
@@ -67,7 +79,7 @@ function parseArguments(argv) {
|
|||||||
gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || DEFAULT_GATEWAY).replace(/\/$/, ''),
|
gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || DEFAULT_GATEWAY).replace(/\/$/, ''),
|
||||||
username: values.get('username') || process.env.ADMIN_TEST_USERNAME || DEFAULT_USERNAME,
|
username: values.get('username') || process.env.ADMIN_TEST_USERNAME || DEFAULT_USERNAME,
|
||||||
password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || DEFAULT_PASSWORD,
|
password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || DEFAULT_PASSWORD,
|
||||||
modules: (values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean),
|
modules: normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)),
|
||||||
output: values.get('output') || '',
|
output: values.get('output') || '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,10 +533,8 @@ export class AcceptanceRun {
|
|||||||
|
|
||||||
async preparePhase3ContainerFixture() {
|
async preparePhase3ContainerFixture() {
|
||||||
const originalMarker = this.marker
|
const originalMarker = this.marker
|
||||||
// Keep the shared orchestration marker stable. Downstream loading,
|
// Isolate order idempotency, then publish the fixture under the run marker.
|
||||||
// overview and finance modules select fixtures by context.marker; using a
|
const fixtureMarker = `${originalMarker}-CAB`
|
||||||
// private -CAB suffix here made the producer invisible to those modules.
|
|
||||||
const fixtureMarker = originalMarker
|
|
||||||
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
|
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
|
||||||
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
|
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
|
||||||
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
|
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
|
||||||
@@ -546,7 +556,8 @@ export class AcceptanceRun {
|
|||||||
this.createdResources.orchestrationFixtures = {
|
this.createdResources.orchestrationFixtures = {
|
||||||
...(this.createdResources.orchestrationFixtures || {}),
|
...(this.createdResources.orchestrationFixtures || {}),
|
||||||
phase3Container: fixture ? {
|
phase3Container: fixture ? {
|
||||||
marker: fixtureMarker,
|
marker: originalMarker,
|
||||||
|
sourceMarker: fixtureMarker,
|
||||||
tenantId: fixture.tenantId,
|
tenantId: fixture.tenantId,
|
||||||
orderId: fixture.orderId || this.createdResources.orders?.orderId,
|
orderId: fixture.orderId || this.createdResources.orders?.orderId,
|
||||||
inboundId: fixture.inboundId,
|
inboundId: fixture.inboundId,
|
||||||
@@ -639,21 +650,26 @@ export class AcceptanceRun {
|
|||||||
Number(page?.total) === records.length && records.length < 200,
|
Number(page?.total) === records.length && records.length < 200,
|
||||||
`total=${page?.total ?? 'missing'}, records=${records.length}, normal=${locations.length}`, options)
|
`total=${page?.total ?? 'missing'}, records=${records.length}, normal=${locations.length}`, options)
|
||||||
const disabled = []
|
const disabled = []
|
||||||
|
const skippedOccupied = []
|
||||||
for (const location of locations) {
|
for (const location of locations) {
|
||||||
const response = await this.request('PATCH',
|
const response = await this.request('PATCH',
|
||||||
`/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`,
|
`/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||||
{ status: 'DISABLED', version: Number(location.version) })
|
{ status: 'DISABLED', version: Number(location.version) })
|
||||||
const success = response?.http === 200 && String(response?.body?.code) === '200'
|
const success = response?.http === 200 && String(response?.body?.code) === '200'
|
||||||
this.assert(module, `临时停用 A0 隔离库位 ${location.locationCode}`, response, success,
|
const occupiedConflict = response?.http === 200
|
||||||
`locationId=${location.id}, beforeVersion=${location.version}`, options)
|
&& String(response?.body?.code) === 'A0409'
|
||||||
|
&& String(response?.body?.message) === '预占或占用中的库位不能停用'
|
||||||
|
this.assert(module, `临时停用 A0 隔离库位 ${location.locationCode}`, response, success || occupiedConflict,
|
||||||
|
`locationId=${location.id}, beforeVersion=${location.version}${occupiedConflict ? ', skipped=active-resource' : ''}`, options)
|
||||||
if (success) disabled.push({ id: String(location.id), code: location.locationCode, version: Number(location.version) + 1 })
|
if (success) disabled.push({ id: String(location.id), code: location.locationCode, version: Number(location.version) + 1 })
|
||||||
|
if (occupiedConflict) skippedOccupied.push({ id: String(location.id), code: location.locationCode })
|
||||||
}
|
}
|
||||||
const availableResponse = await this.request('GET',
|
const availableResponse = await this.request('GET',
|
||||||
`/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`)
|
`/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`)
|
||||||
const available = this.apiSuccess(module, '停用后回查收货仓无可用普通库位', availableResponse, '', options)
|
const available = this.apiSuccess(module, '停用后回查收货仓无可用普通库位', availableResponse, '', options)
|
||||||
this.assert(module, '测量前 A0 溢出条件成立', availableResponse,
|
this.assert(module, '测量前 A0 溢出条件成立', availableResponse,
|
||||||
Array.isArray(available) && available.length === 0 && disabled.length === locations.length,
|
Array.isArray(available) && available.length === 0,
|
||||||
`disabled=${disabled.length}, available=${Array.isArray(available) ? available.length : 'missing'}`, options)
|
`disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, available=${Array.isArray(available) ? available.length : 'missing'}`, options)
|
||||||
return disabled
|
return disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,34 @@ export async function run(context) {
|
|||||||
- 缺少已确认契约时记录失败或 `deploymentBlocker`,不得模拟成功。
|
- 缺少已确认契约时记录失败或 `deploymentBlocker`,不得模拟成功。
|
||||||
- 一个模块只修改自己的 `.mjs`,不要修改其他模块文件。
|
- 一个模块只修改自己的 `.mjs`,不要修改其他模块文件。
|
||||||
|
|
||||||
|
## 近期业务模块与静态合同快速索引
|
||||||
|
|
||||||
|
| 业务链路 | HTTP 模块/共享前置 | 离线静态合同 |
|
||||||
|
|---|---|---|
|
||||||
|
| 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` |
|
||||||
|
| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
|
||||||
|
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
|
||||||
|
| 三期柜次、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||||
|
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
|
||||||
|
| 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` |
|
||||||
|
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
|
||||||
|
|
||||||
|
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
|
||||||
|
|
||||||
|
## 消息中心模块
|
||||||
|
|
||||||
|
`notifications.mjs` 覆盖平台消息模板的查询、新增、编辑、版本冲突、激活与最终停用,以及未来定时消息任务的创建、幂等重放、列表查询、取消和非法重试。模块只创建未来定时任务并在结束前取消,不实际投递验收消息。定向 HTTP 验收需要当前对话明确授权:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node scripts/admin-test-env-acceptance.mjs --modules notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
离线静态合同可安全执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node --test scripts/admin-test-env-modules/notifications-contract.test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
## 供应商模块
|
## 供应商模块
|
||||||
|
|
||||||
`suppliers.mjs` 覆盖平台公共供应商的列表、统计、新增、详情、编辑、启停及主要错误边界。执行:
|
`suppliers.mjs` 覆盖平台公共供应商的列表、统计、新增、详情、编辑、启停及主要错误边界。执行:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ test('fee writes use verified Firstleg paths and do not fabricate historical IDs
|
|||||||
assert.match(source, /const basePath = `\/api\/platform\/v1\/loading-plans\//)
|
assert.match(source, /const basePath = `\/api\/platform\/v1\/loading-plans\//)
|
||||||
assert.match(source, /sourceType: detail\?\.sourceType \|\| 'MANUAL'/)
|
assert.match(source, /sourceType: detail\?\.sourceType \|\| 'MANUAL'/)
|
||||||
assert.match(source, /created\?\.marker === context\.marker/)
|
assert.match(source, /created\?\.marker === context\.marker/)
|
||||||
assert.match(source, /\/fees`/)
|
assert.match(source, /fee-details\?accountId=/)
|
||||||
assert.match(source, /chargeUnit === 'KG'/)
|
assert.match(source, /chargeUnit === 'KG'/)
|
||||||
assert.match(source, /chargeUnit === 'CBM'/)
|
assert.match(source, /chargeUnit === 'CBM'/)
|
||||||
})
|
})
|
||||||
@@ -65,7 +65,7 @@ function hookSet({ missing = false } = {}) {
|
|||||||
loadSupplier: async () => missing ? null : ({ id: 'PARTNER-1', partnerName: '往来单位一', status: 'ACTIVE' }),
|
loadSupplier: async () => missing ? null : ({ id: 'PARTNER-1', partnerName: '往来单位一', status: 'ACTIVE' }),
|
||||||
uploadVoucher: async () => missing ? null : 'VOUCHER-1',
|
uploadVoucher: async () => missing ? null : 'VOUCHER-1',
|
||||||
verifyContainerCostSnapshotInput: async () => ({ valid: true, items: [{ accountId: '701', orderId: 'O-AUTO1', volumeCbm: 0.072 }] }),
|
verifyContainerCostSnapshotInput: async () => ({ valid: true, items: [{ accountId: '701', orderId: 'O-AUTO1', volumeCbm: 0.072 }] }),
|
||||||
calculateAutomaticFee: async (context, resource) => {
|
loadPublishedAutomaticFee: async (context, resource) => {
|
||||||
automaticIndex += 1
|
automaticIndex += 1
|
||||||
const weight = automaticIndex % 2 === 1
|
const weight = automaticIndex % 2 === 1
|
||||||
return { feeSourceId: `F-${automaticIndex}`, orderId: resource.orderId, inboundId: resource.inboundId,
|
return { feeSourceId: `F-${automaticIndex}`, orderId: resource.orderId, inboundId: resource.inboundId,
|
||||||
@@ -162,6 +162,40 @@ test('manual fee approval path keeps accountId as a query parameter', () => {
|
|||||||
assert.doesNotMatch(source, /fee-details\?accountId=\$\{encodeURIComponent\(resource\.tenantId\)\}`\)/)
|
assert.doesNotMatch(source, /fee-details\?accountId=\$\{encodeURIComponent\(resource\.tenantId\)\}`\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('automatic fixture reuses the published review-chain source without recalculating at WAIT_SPLIT', () => {
|
||||||
|
assert.match(source, /loadPublishedAutomaticFee/)
|
||||||
|
assert.match(source, /sourceType === 'FBA_PRICING'/)
|
||||||
|
assert.match(source, /reviewStatus === 'APPROVED'/)
|
||||||
|
assert.match(source, /publicationStatus === 'PUBLISHED'/)
|
||||||
|
assert.match(source, /settlementIncluded === true/)
|
||||||
|
assert.match(source, /const feeSourceId = id\(published\.sourceFeeId\)/)
|
||||||
|
assert.doesNotMatch(source, /\/fees\/calculate/)
|
||||||
|
assert.doesNotMatch(source, /\/fees\/confirm/)
|
||||||
|
assert.doesNotMatch(source, /\/fees\/send/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing published automatic source is a prerequisite blocker and never emits a fixture source', async () => {
|
||||||
|
const context = fakeContext()
|
||||||
|
let automaticReads = 0
|
||||||
|
context.financeFixtureHooks = {
|
||||||
|
...hookSet(),
|
||||||
|
loadPublishedAutomaticFee: async (ctx, resource) => {
|
||||||
|
automaticReads += 1
|
||||||
|
ctx.addStep('finance-full-fixtures', '审核链已生成可结算自动费用来源', null, false,
|
||||||
|
`inboundId=${resource.inboundId}; fixture 不重复试算/确认/发布`, { deploymentBlocker: true })
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await run(context)
|
||||||
|
|
||||||
|
assert.equal(automaticReads, 4)
|
||||||
|
assert.equal(context.createdResources.financeFullFixtures.feeSources
|
||||||
|
.filter((item) => item.sourceType === 'FBA_PRICING').length, 0)
|
||||||
|
assert.ok(context.steps.some((step) => step.deploymentBlocker
|
||||||
|
&& step.name.includes('可结算自动费用来源')))
|
||||||
|
})
|
||||||
|
|
||||||
test('fixture creates four real receivable manual sources before payable source', () => {
|
test('fixture creates four real receivable manual sources before payable source', () => {
|
||||||
assert.match(source, /const receivableResources = measured\.slice\(0, 4\)/)
|
assert.match(source, /const receivableResources = measured\.slice\(0, 4\)/)
|
||||||
assert.match(source, /feeItems\.RECEIVABLE, 'RECEIVABLE'/)
|
assert.match(source, /feeItems\.RECEIVABLE, 'RECEIVABLE'/)
|
||||||
|
|||||||
@@ -130,11 +130,11 @@ async function createManualFee(context, resource, feeItem, direction, amount, ba
|
|||||||
chargeableWeightKg: detail?.chargeableWeightKg ?? null, chargeableVolumeCbm: detail?.chargeableVolumeCbm ?? null }
|
chargeableWeightKg: detail?.chargeableWeightKg ?? null, chargeableVolumeCbm: detail?.chargeableVolumeCbm ?? null }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function calculateAutomaticFee(context, resource) {
|
async function loadPublishedAutomaticFee(context, resource) {
|
||||||
if (context.financeFixtureHooks?.calculateAutomaticFee) return context.financeFixtureHooks.calculateAutomaticFee(context, resource)
|
if (context.financeFixtureHooks?.loadPublishedAutomaticFee) return context.financeFixtureHooks.loadPublishedAutomaticFee(context, resource)
|
||||||
const pricingInboundResponse = await context.request('GET',
|
const pricingInboundResponse = await context.request('GET',
|
||||||
`/api/platform/inbounds/${encodeURIComponent(resource.inboundId)}?tenantId=${encodeURIComponent(resource.tenantId)}`)
|
`/api/platform/inbounds/${encodeURIComponent(resource.inboundId)}?tenantId=${encodeURIComponent(resource.tenantId)}`)
|
||||||
const pricingInbound = context.apiSuccess(MODULE, '计费前回读入库主表计费快照', pricingInboundResponse,
|
const pricingInbound = context.apiSuccess(MODULE, '回读审核完成后的入库主表状态', pricingInboundResponse,
|
||||||
`inboundId=${resource.inboundId}`, OPTIONS)
|
`inboundId=${resource.inboundId}`, OPTIONS)
|
||||||
const pricingSummary = pricingInbound?.summary || {}
|
const pricingSummary = pricingInbound?.summary || {}
|
||||||
const actualWeightKg = Number(pricingSummary.actualGrossWeightKg ?? pricingSummary.actualWeightKg)
|
const actualWeightKg = Number(pricingSummary.actualGrossWeightKg ?? pricingSummary.actualWeightKg)
|
||||||
@@ -144,31 +144,33 @@ async function calculateAutomaticFee(context, resource) {
|
|||||||
`服务端计费实际读取 fl_inbound_order.actual_gross_weight_kg/actual_volume_cbm;inboundId=${resource.inboundId}, actualWeightKg=${actualWeightKg || 'missing'}, actualVolumeCbm=${actualVolumeCbm || 'missing'};测量表有值但主表未同步`)
|
`服务端计费实际读取 fl_inbound_order.actual_gross_weight_kg/actual_volume_cbm;inboundId=${resource.inboundId}, actualWeightKg=${actualWeightKg || 'missing'}, actualVolumeCbm=${actualVolumeCbm || 'missing'};测量表有值但主表未同步`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const base = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fees`
|
const listPath = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details?accountId=${encodeURIComponent(resource.tenantId)}`
|
||||||
const query = `tenantId=${encodeURIComponent(resource.tenantId)}`
|
const listResponse = await context.request('GET', listPath)
|
||||||
const calculateResponse = await request(context, 'POST', `${base}/calculate?${query}`, {})
|
const details = context.apiSuccess(MODULE, '回读审核链已发布自动费用来源', listResponse,
|
||||||
const calculated = context.apiSuccess(MODULE, '按审核测量快照执行真实自动计费', calculateResponse,
|
`inboundId=${resource.inboundId}`, OPTIONS)
|
||||||
`orderId=${resource.orderId}, inboundId=${resource.inboundId}`, OPTIONS)
|
const published = rows(details).find((item) => item?.sourceType === 'FBA_PRICING'
|
||||||
const feeSourceId = id(calculated?.feeId)
|
&& item?.reviewStatus === 'APPROVED' && item?.publicationStatus === 'PUBLISHED'
|
||||||
const billingBasis = calculated?.chargeUnit === 'KG' ? 'WEIGHT_KG'
|
&& item?.settlementIncluded === true)
|
||||||
: calculated?.chargeUnit === 'CBM' ? 'VOLUME_CBM' : null
|
if (!published) {
|
||||||
const responseWeightKg = Number(calculated?.actualWeightKg)
|
blocker(context, '审核链已生成可结算自动费用来源',
|
||||||
const responseVolumeCbm = Number(calculated?.actualVolumeCbm)
|
`inboundId=${resource.inboundId}, processStatus=${pricingSummary.processStatus || pricingInbound?.processStatus || 'missing'}, feeDetails=${rows(details).length};测量审核完成链应在进入 WAIT_SPLIT 前生成 APPROVED + PUBLISHED 的 FBA_PRICING 来源,fixture 不重复试算/确认/发布`)
|
||||||
context.assert(MODULE, '自动费用返回来源、金额和计费依据', calculateResponse,
|
return null
|
||||||
Boolean(feeSourceId) && Boolean(billingBasis) && Number(calculated?.totalAmount) >= 0
|
}
|
||||||
|
const feeSourceId = id(published.sourceFeeId)
|
||||||
|
const billingBasis = published.chargeUnit === 'KG' ? 'WEIGHT_KG'
|
||||||
|
: published.chargeUnit === 'CBM' ? 'VOLUME_CBM' : null
|
||||||
|
const responseWeightKg = Number(published.actualGrossWeightKg ?? actualWeightKg)
|
||||||
|
const responseVolumeCbm = Number(published.actualVolumeCbm ?? actualVolumeCbm)
|
||||||
|
context.assert(MODULE, '已发布自动费用来源、金额和计费依据完整', listResponse,
|
||||||
|
Boolean(feeSourceId) && Boolean(billingBasis) && Number(published.amount) >= 0
|
||||||
&& responseWeightKg > 0 && responseVolumeCbm > 0,
|
&& responseWeightKg > 0 && responseVolumeCbm > 0,
|
||||||
`feeSourceId=${feeSourceId || 'missing'}, chargeUnit=${calculated?.chargeUnit || 'missing'}, amount=${calculated?.totalAmount ?? 'missing'}, actualWeightKg=${responseWeightKg || 'missing'}, actualVolumeCbm=${responseVolumeCbm || 'missing'}`, OPTIONS)
|
`feeSourceId=${feeSourceId || 'missing'}, detailId=${id(published.id) || 'missing'}, chargeUnit=${published.chargeUnit || 'missing'}, amount=${published.amount ?? 'missing'}, actualWeightKg=${responseWeightKg || 'missing'}, actualVolumeCbm=${responseVolumeCbm || 'missing'}`, OPTIONS)
|
||||||
if (!feeSourceId || !billingBasis) return null
|
if (!feeSourceId || !billingBasis) return null
|
||||||
const confirmResponse = await request(context, 'POST', `${base}/confirm?${query}`)
|
|
||||||
const confirmed = context.apiSuccess(MODULE, '确认真实自动费用快照', confirmResponse, '', OPTIONS)
|
|
||||||
const sendResponse = await request(context, 'POST', `${base}/send?${query}`)
|
|
||||||
const sent = context.apiSuccess(MODULE, '发布真实自动费用来源', sendResponse, '', OPTIONS)
|
|
||||||
return { feeSourceId, orderId: id(resource.orderId), inboundId: id(resource.inboundId), sourceType: 'FBA_PRICING',
|
return { feeSourceId, orderId: id(resource.orderId), inboundId: id(resource.inboundId), sourceType: 'FBA_PRICING',
|
||||||
direction: 'RECEIVABLE', currency: sent?.currency || confirmed?.currency || calculated.currency,
|
direction: 'RECEIVABLE', currency: published.currency,
|
||||||
amount: Number(sent?.totalAmount ?? confirmed?.totalAmount ?? calculated.totalAmount),
|
amount: Number(published.amount), businessDate: String(published.businessDate || published.publishedAt || '').slice(0, 10),
|
||||||
businessDate: String(sent?.sentAt || confirmed?.confirmedAt || calculated.calculatedAt || '').slice(0, 10),
|
billingBasis, chargeableQuantity: published.chargeableQuantity,
|
||||||
billingBasis, chargeableQuantity: sent?.chargeableQuantity ?? calculated.chargeableQuantity,
|
chargeUnit: published.chargeUnit,
|
||||||
chargeUnit: sent?.chargeUnit || calculated.chargeUnit,
|
|
||||||
actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm }
|
actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +330,7 @@ export async function run(context) {
|
|||||||
}
|
}
|
||||||
const fixtures = []
|
const fixtures = []
|
||||||
for (const resource of measured.slice(0, 4)) {
|
for (const resource of measured.slice(0, 4)) {
|
||||||
const fee = await calculateAutomaticFee(context, resource)
|
const fee = await loadPublishedAutomaticFee(context, resource)
|
||||||
if (fee) fixtures.push(fee)
|
if (fee) fixtures.push(fee)
|
||||||
}
|
}
|
||||||
for (const basis of ['WEIGHT_KG', 'VOLUME_CBM']) {
|
for (const basis of ['WEIGHT_KG', 'VOLUME_CBM']) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('measurement acceptance scans the stable box code frozen by first print', () => {
|
||||||
|
assert.match(source, /const boxCode = `\$\{inboundCode\}#\$\{originalPackageCount\}-1`/)
|
||||||
|
assert.doesNotMatch(source, /#V\d+#/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reprint and Mojia scan share the same frozen box code variable', () => {
|
||||||
|
assert.match(source, /重复整单打印复用已冻结箱码映射[\s\S]*boxCode=\$\{boxCode\}/)
|
||||||
|
assert.match(source, /const firstScanResponse = await multipartRequest[\s\S]*boxCode, \.\.\.firstDimensions/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const moduleSource = fs.readFileSync(path.join(here, 'notifications.mjs'), 'utf8')
|
||||||
|
const acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8')
|
||||||
|
|
||||||
|
test('消息中心模块由全量编排注入 context 且不直连环境', () => {
|
||||||
|
assert.match(moduleSource, /export async function run\(context\)/)
|
||||||
|
assert.doesNotMatch(moduleSource, /https?:\/\//)
|
||||||
|
assert.doesNotMatch(moduleSource, /ADMIN_TEST_GATEWAY|process\.env|fetch\(/)
|
||||||
|
assert.match(acceptance, /import\(new URL\(`\.\/admin-test-env-modules\/\$\{module\}\.mjs`/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('正式 Admin 全量验收在 system 后注册消息中心', () => {
|
||||||
|
assert.match(acceptance, /CONFIGURED_MODULES\s*=\s*\[[^\]]*'system',\s*'notifications'/s)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('覆盖模板生命周期、版本冲突、定时任务、幂等、查询和取消', () => {
|
||||||
|
for (const token of [
|
||||||
|
'创建消息模板草稿', '编辑 DRAFT 消息模板', '旧模板版本编辑被拒绝', '激活消息模板',
|
||||||
|
'创建未来定时消息任务', '同幂等键重放定时消息任务', '按任务号和状态查询消息任务',
|
||||||
|
'取消未来定时消息任务', '已取消任务禁止重试', '停用本轮消息模板',
|
||||||
|
]) assert.match(moduleSource, new RegExp(token))
|
||||||
|
assert.match(moduleSource, /recipientType:\s*'PLATFORM_ROLE'/)
|
||||||
|
assert.match(moduleSource, /roleCode:\s*'PLATFORM_ADMIN'/)
|
||||||
|
assert.match(moduleSource, /Date\.now\(\) \+ 365 \* 24 \* 60 \* 60 \* 1000/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('异常路径会取消定时任务并停用模板,不实际投递验收消息', () => {
|
||||||
|
assert.match(moduleSource, /taskStatus === 'SCHEDULED'/)
|
||||||
|
assert.match(moduleSource, /targetStatus:\s*'DISABLED'/)
|
||||||
|
assert.match(moduleSource, /finalTemplateStatus/)
|
||||||
|
assert.doesNotMatch(moduleSource, /scheduledAt:\s*null/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
const MODULE = 'notifications'
|
||||||
|
const OPTIONS = { scenario: 'platform-notification-administration', deploymentBlocker: true }
|
||||||
|
const TEMPLATE_BASE = '/api/platform/v1/notification-templates'
|
||||||
|
const TASK_BASE = '/api/platform/v1/notification-tasks'
|
||||||
|
const CREATE_PATH = '/api/platform/v1/notifications'
|
||||||
|
|
||||||
|
function id(value) { return value == null ? '' : String(value) }
|
||||||
|
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||||
|
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
|
||||||
|
function hasTrace(response) { return Boolean(response?.requestId) && Boolean(response?.traceId) }
|
||||||
|
function isConflict(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
|
||||||
|
function query(base, values) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(values)) if (value !== '' && value != null) params.set(key, String(value))
|
||||||
|
return `${base}?${params}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(context) {
|
||||||
|
const suffix = context.marker.replace(/[^0-9A-Za-z]/g, '').slice(-20).toUpperCase()
|
||||||
|
const templateCode = `ACPT_NOTIFICATION_${suffix}`.slice(0, 64)
|
||||||
|
const category = 'ACCEPTANCE'
|
||||||
|
const templateCreate = {
|
||||||
|
templateCode,
|
||||||
|
templateName: `全量验收消息模板-${suffix}`,
|
||||||
|
category,
|
||||||
|
titleTemplate: '全量验收通知 {{marker}}',
|
||||||
|
bodyTemplate: '这是 {{marker}} 的消息中心全量验收定时任务。',
|
||||||
|
variableWhitelist: ['marker'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const baselineResponse = await context.request('GET', query(TEMPLATE_BASE, { page: 1, size: 20, templateCode }))
|
||||||
|
const baseline = context.apiSuccess(MODULE, '按 CODE 查询消息模板基线', baselineResponse, `templateCode=${templateCode}`, OPTIONS)
|
||||||
|
context.assert(MODULE, '消息模板分页结构完整', baselineResponse,
|
||||||
|
Array.isArray(baseline?.records) && Number(baseline?.page) === 1 && Number(baseline?.size) === 20,
|
||||||
|
`records=${records(baseline).length}, total=${baseline?.total ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const invalidResponse = await context.request('POST', TEMPLATE_BASE, {
|
||||||
|
...templateCreate,
|
||||||
|
templateCode: `ACPT_INVALID_${suffix}`.slice(0, 64),
|
||||||
|
variableWhitelist: [],
|
||||||
|
})
|
||||||
|
context.assert(MODULE, '模板占位变量未列入白名单时被拒绝且返回追踪信息', invalidResponse,
|
||||||
|
invalidResponse.http === 200 && ['A0422', '422'].includes(codeOf(invalidResponse)) && hasTrace(invalidResponse),
|
||||||
|
`HTTP=${invalidResponse.http}, code=${codeOf(invalidResponse)}, requestId=${invalidResponse.requestId || 'missing'}, traceId=${invalidResponse.traceId || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const createResponse = await context.request('POST', TEMPLATE_BASE, templateCreate)
|
||||||
|
let template = context.apiSuccess(MODULE, '创建消息模板草稿', createResponse, `templateCode=${templateCode}`, OPTIONS)
|
||||||
|
const templateId = id(template?.id)
|
||||||
|
context.assert(MODULE, '新建模板为 DRAFT 且仅支持站内信', createResponse,
|
||||||
|
Boolean(templateId) && template?.status === 'DRAFT' && template?.channel === 'IN_APP' && Number(template?.version) === 1,
|
||||||
|
`templateId=${templateId || 'missing'}, status=${template?.status || 'missing'}, channel=${template?.channel || 'missing'}`, OPTIONS)
|
||||||
|
if (!templateId) return
|
||||||
|
|
||||||
|
let taskId = ''
|
||||||
|
let taskStatus = ''
|
||||||
|
let finalTemplateStatus = template?.status || 'UNKNOWN'
|
||||||
|
try {
|
||||||
|
const duplicateResponse = await context.request('POST', TEMPLATE_BASE, templateCreate)
|
||||||
|
context.assert(MODULE, '重复模板 CODE 返回冲突并保留追踪信息', duplicateResponse,
|
||||||
|
isConflict(duplicateResponse) && hasTrace(duplicateResponse),
|
||||||
|
`code=${codeOf(duplicateResponse)}, requestId=${duplicateResponse.requestId || 'missing'}, traceId=${duplicateResponse.traceId || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const updateResponse = await context.request('PUT', `${TEMPLATE_BASE}/${encodeURIComponent(templateId)}`, {
|
||||||
|
templateName: `全量验收消息模板已编辑-${suffix}`,
|
||||||
|
category,
|
||||||
|
titleTemplate: '全量验收通知已编辑 {{marker}}',
|
||||||
|
bodyTemplate: '这是 {{marker}} 的消息中心全量验收定时任务,模板已经编辑。',
|
||||||
|
variableWhitelist: ['marker'],
|
||||||
|
version: template.version,
|
||||||
|
})
|
||||||
|
template = context.apiSuccess(MODULE, '编辑 DRAFT 消息模板', updateResponse, '', OPTIONS)
|
||||||
|
context.assert(MODULE, '模板编辑结果及版本已持久化', updateResponse,
|
||||||
|
template?.templateName === `全量验收消息模板已编辑-${suffix}` && Number(template?.version) === 2,
|
||||||
|
`name=${template?.templateName || 'missing'}, version=${template?.version ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const staleUpdate = await context.request('PUT', `${TEMPLATE_BASE}/${encodeURIComponent(templateId)}`, {
|
||||||
|
templateName: templateCreate.templateName,
|
||||||
|
category,
|
||||||
|
titleTemplate: templateCreate.titleTemplate,
|
||||||
|
bodyTemplate: templateCreate.bodyTemplate,
|
||||||
|
variableWhitelist: ['marker'],
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
context.assert(MODULE, '旧模板版本编辑被拒绝且返回追踪信息', staleUpdate,
|
||||||
|
isConflict(staleUpdate) && hasTrace(staleUpdate),
|
||||||
|
`code=${codeOf(staleUpdate)}, requestId=${staleUpdate.requestId || 'missing'}, traceId=${staleUpdate.traceId || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const activateResponse = await context.request('PUT', `${TEMPLATE_BASE}/${encodeURIComponent(templateId)}/status`, {
|
||||||
|
targetStatus: 'ACTIVE', version: template?.version,
|
||||||
|
})
|
||||||
|
template = context.apiSuccess(MODULE, '激活消息模板', activateResponse, '', OPTIONS)
|
||||||
|
finalTemplateStatus = template?.status || finalTemplateStatus
|
||||||
|
context.assert(MODULE, '消息模板激活且版本递增', activateResponse,
|
||||||
|
template?.status === 'ACTIVE' && Number(template?.version) === 3,
|
||||||
|
`status=${template?.status || 'missing'}, version=${template?.version ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const scheduledAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 19)
|
||||||
|
const idempotencyKey = `${context.marker}-notification-scheduled`.slice(0, 128)
|
||||||
|
const taskCreate = {
|
||||||
|
tenantId: 0,
|
||||||
|
templateCode,
|
||||||
|
recipientType: 'PLATFORM_ROLE',
|
||||||
|
roleCode: 'PLATFORM_ADMIN',
|
||||||
|
variables: { marker: context.marker },
|
||||||
|
scheduledAt,
|
||||||
|
idempotencyKey,
|
||||||
|
sourceEventId: `${context.marker}-notification-acceptance`.slice(0, 128),
|
||||||
|
}
|
||||||
|
const taskResponse = await context.request('POST', CREATE_PATH, taskCreate)
|
||||||
|
let task = context.apiSuccess(MODULE, '创建未来定时消息任务', taskResponse, `scheduledAt=${scheduledAt}`, OPTIONS)
|
||||||
|
taskId = id(task?.id); taskStatus = task?.status || ''
|
||||||
|
context.assert(MODULE, '定时任务创建为 SCHEDULED 且解析平台角色接收人', taskResponse,
|
||||||
|
Boolean(taskId) && task?.status === 'SCHEDULED' && Number(task?.recipientCount) > 0 && task?.duplicated === false,
|
||||||
|
`taskId=${taskId || 'missing'}, status=${task?.status || 'missing'}, recipientCount=${task?.recipientCount ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const replayResponse = await context.request('POST', CREATE_PATH, taskCreate)
|
||||||
|
const replay = context.apiSuccess(MODULE, '同幂等键重放定时消息任务', replayResponse, '', OPTIONS)
|
||||||
|
context.assert(MODULE, '幂等重放返回同一任务且标记 duplicated', replayResponse,
|
||||||
|
id(replay?.id) === taskId && replay?.duplicated === true,
|
||||||
|
`original=${taskId || 'missing'}, replay=${id(replay?.id) || 'missing'}, duplicated=${replay?.duplicated ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const taskListResponse = await context.request('GET', query(TASK_BASE, { page: 1, size: 20, taskNo: task?.taskNo, category, status: 'SCHEDULED' }))
|
||||||
|
const taskPage = context.apiSuccess(MODULE, '按任务号和状态查询消息任务', taskListResponse, `taskNo=${task?.taskNo || 'missing'}`, OPTIONS)
|
||||||
|
context.assert(MODULE, '任务列表命中本轮定时任务', taskListResponse,
|
||||||
|
records(taskPage).some((row) => id(row?.id) === taskId && row?.status === 'SCHEDULED'),
|
||||||
|
`taskId=${taskId || 'missing'}, records=${records(taskPage).length}`, OPTIONS)
|
||||||
|
|
||||||
|
if (taskId) {
|
||||||
|
const cancelResponse = await context.request('PUT', `${TASK_BASE}/${encodeURIComponent(taskId)}/cancel`)
|
||||||
|
task = context.apiSuccess(MODULE, '取消未来定时消息任务', cancelResponse, '', OPTIONS)
|
||||||
|
taskStatus = task?.status || taskStatus
|
||||||
|
context.assert(MODULE, '定时消息任务最终为 CANCELLED', cancelResponse,
|
||||||
|
task?.status === 'CANCELLED', `status=${task?.status || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const retryResponse = await context.request('PUT', `${TASK_BASE}/${encodeURIComponent(taskId)}/retry`)
|
||||||
|
context.assert(MODULE, '已取消任务禁止重试且返回追踪信息', retryResponse,
|
||||||
|
isConflict(retryResponse) && hasTrace(retryResponse),
|
||||||
|
`code=${codeOf(retryResponse)}, requestId=${retryResponse.requestId || 'missing'}, traceId=${retryResponse.traceId || 'missing'}`, OPTIONS)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (taskId && taskStatus === 'SCHEDULED') {
|
||||||
|
const cleanupTask = await context.request('PUT', `${TASK_BASE}/${encodeURIComponent(taskId)}/cancel`)
|
||||||
|
const cleanedTask = context.apiSuccess(MODULE, '异常路径取消未来定时消息任务', cleanupTask, `taskId=${taskId}`, OPTIONS)
|
||||||
|
taskStatus = cleanedTask?.status || taskStatus
|
||||||
|
}
|
||||||
|
if (template?.status === 'ACTIVE') {
|
||||||
|
const disableResponse = await context.request('PUT', `${TEMPLATE_BASE}/${encodeURIComponent(templateId)}/status`, {
|
||||||
|
targetStatus: 'DISABLED', version: template.version,
|
||||||
|
})
|
||||||
|
template = context.apiSuccess(MODULE, '停用本轮消息模板', disableResponse, `templateId=${templateId}`, OPTIONS)
|
||||||
|
finalTemplateStatus = template?.status || finalTemplateStatus
|
||||||
|
context.assert(MODULE, '消息模板最终为 DISABLED', disableResponse,
|
||||||
|
template?.status === 'DISABLED', `status=${template?.status || 'missing'}`, OPTIONS)
|
||||||
|
}
|
||||||
|
context.createdResources.notifications = {
|
||||||
|
marker: context.marker, templateId, templateCode, finalTemplateStatus,
|
||||||
|
taskId: taskId || null, taskStatus: taskStatus || null, retained: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ export async function run(context) {
|
|||||||
const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` })
|
const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` })
|
||||||
context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version)
|
context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version)
|
||||||
const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change')
|
const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change')
|
||||||
|
let allocatedContainerBookingId = id(changeConfirmed?.containerAllocations?.[0]?.containerBookingId)
|
||||||
if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
|
if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
|
||||||
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
|
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
|
||||||
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
|
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
|
||||||
@@ -70,15 +71,9 @@ export async function run(context) {
|
|||||||
context.assert(MODULE, '订舱存在匹配本轮柜型的箱型需求', changed,
|
context.assert(MODULE, '订舱存在匹配本轮柜型的箱型需求', changed,
|
||||||
Boolean(requirement?.id), `requirementId=${id(requirement?.id) || 'missing'}`, OPTIONS)
|
Boolean(requirement?.id), `requirementId=${id(requirement?.id) || 'missing'}`, OPTIONS)
|
||||||
if (!requirement?.id || id(containerFlow?.tenantId) !== id(changeConfirmed?.oceanBooking?.customerId)) return
|
if (!requirement?.id || id(containerFlow?.tenantId) !== id(changeConfirmed?.oceanBooking?.customerId)) return
|
||||||
const allocation = await request(context, 'POST', `${path}/allocations`, {
|
context.assert(MODULE, '确认订舱自动生成并关联柜次', changed,
|
||||||
requirementId: id(requirement.id), containerBookingId: id(containerFlow.containerBookingId), expectedVersion: version,
|
Boolean(allocatedContainerBookingId) && Number.isInteger(version),
|
||||||
}, { 'Idempotency-Key': `${context.marker}-ocean-container-allocation`.slice(0, 128) })
|
`containerBookingId=${allocatedContainerBookingId || 'missing'}, version=${version}`, OPTIONS)
|
||||||
const allocated = context.apiSuccess(MODULE, '放舱前分配本轮柜次', allocation, '', OPTIONS)
|
|
||||||
version = Number(allocated?.oceanBooking?.version)
|
|
||||||
context.assert(MODULE, '订舱柜次分配持久化且版本递增', allocation,
|
|
||||||
allocated?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId))
|
|
||||||
&& Number.isInteger(version),
|
|
||||||
`containerBookingId=${id(containerFlow.containerBookingId)}, version=${version}`, OPTIONS)
|
|
||||||
}
|
}
|
||||||
const released = await request(context, 'POST', `${path}/release`, {
|
const released = await request(context, 'POST', `${path}/release`, {
|
||||||
expectedVersion: version, billOfLadingNo: `BL-${suffix}`, vesselName: `放舱船-${suffix}`,
|
expectedVersion: version, billOfLadingNo: `BL-${suffix}`, vesselName: `放舱船-${suffix}`,
|
||||||
@@ -92,12 +87,12 @@ export async function run(context) {
|
|||||||
context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS)
|
context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS)
|
||||||
context.assert(MODULE, '订舱状态历史、审计操作者、柜次关联和终态完整', final,
|
context.assert(MODULE, '订舱状态历史、审计操作者、柜次关联和终态完整', final,
|
||||||
fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 5 && fd.histories.every((h) => h.operatorName)
|
fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 5 && fd.histories.every((h) => h.operatorName)
|
||||||
&& (!containerFlow || fd?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId))),
|
&& fd?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === allocatedContainerBookingId),
|
||||||
`status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, allocations=${fd?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
|
`status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, allocations=${fd?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
|
||||||
context.createdResources.oceanBooking = {
|
context.createdResources.oceanBooking = {
|
||||||
id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker,
|
id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker,
|
||||||
containerBookingId: containerFlow ? id(containerFlow.containerBookingId) : null,
|
containerBookingId: allocatedContainerBookingId || null,
|
||||||
loadingPlanId: containerFlow ? id(containerFlow.loadingPlanId) : null,
|
loadingPlanId: null,
|
||||||
finalStatus: fd?.oceanBooking?.status || null,
|
finalStatus: fd?.oceanBooking?.status || null,
|
||||||
}
|
}
|
||||||
const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`)
|
const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { findPricedOrderRoute } from './orders.mjs'
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('orders fixture verifies the complete pricing tuple before selecting an FBA warehouse', () => {
|
||||||
|
assert.match(source, /\/api\/merchant\/v1\/first-leg\/pricing\/estimate/)
|
||||||
|
for (const field of ['originWarehouseId', 'channelCode', 'destinationCode', 'destinationType',
|
||||||
|
'destinationWarehouseId', 'pricingTaxMode', 'actualWeightKg', 'actualVolumeCbm']) {
|
||||||
|
assert.match(source, new RegExp(`${field}:`))
|
||||||
|
}
|
||||||
|
assert.match(source, /estimateMatches\(response, candidate\)/)
|
||||||
|
assert.doesNotMatch(source, /const destination = records\(fbaPage\)\[0\]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('order payload reuses the tax mode from the successful estimate', () => {
|
||||||
|
assert.match(source, /pricingTaxMode: master\.pricingTaxMode/)
|
||||||
|
assert.doesNotMatch(source, /pricingTaxMode: master\.channel\.pricingTaxModes\[0\]/)
|
||||||
|
assert.match(source, /没有可计价组合/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pricing route accepts only an estimate matching the active version channel and tax tuple', async () => {
|
||||||
|
const requests = []
|
||||||
|
const result = await findPricedOrderRoute({
|
||||||
|
receiving: { id: '100' },
|
||||||
|
destinations: [
|
||||||
|
{ id: '201', warehouseCode: 'ABE2', warehouseName: 'ABE2' },
|
||||||
|
{ id: '202', warehouseCode: 'ABE3', warehouseName: 'ABE3' },
|
||||||
|
],
|
||||||
|
channels: [{ priceBookVersionId: '300', channelCode: 'EXX_RAIL_NY',
|
||||||
|
pricingTaxModes: ['TAX_INCLUDED'] }],
|
||||||
|
concurrency: 1,
|
||||||
|
estimate: async (request) => {
|
||||||
|
requests.push(request)
|
||||||
|
if (request.destinationCode === 'ABE2') {
|
||||||
|
return { http: 200, body: { code: 'A0404' }, requestId: 'request-abe2', traceId: 'trace-abe2' }
|
||||||
|
}
|
||||||
|
return { http: 200, body: { code: '200', data: {
|
||||||
|
priceBookVersionId: '300', channelCode: 'EXX_RAIL_NY', pricingTaxMode: 'TAX_INCLUDED',
|
||||||
|
} } }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(result.priced.destination.warehouseCode, 'ABE3')
|
||||||
|
assert.equal(result.attempts, 2)
|
||||||
|
assert.deepEqual(requests[1], {
|
||||||
|
originWarehouseId: '100', channelCode: 'EXX_RAIL_NY', destinationCode: 'ABE3',
|
||||||
|
destinationType: 'FBA', destinationWarehouseId: '202', destinationName: 'ABE3',
|
||||||
|
pricingTaxMode: 'TAX_INCLUDED', actualWeightKg: 46, actualVolumeCbm: 0.278,
|
||||||
|
})
|
||||||
|
assert.equal(result.failures[0].requestId, 'request-abe2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pricing route rejects a nominal success from a different version or tuple', async () => {
|
||||||
|
const result = await findPricedOrderRoute({
|
||||||
|
receiving: { id: '100' },
|
||||||
|
destinations: [{ id: '202', warehouseCode: 'ABE3', warehouseName: 'ABE3' }],
|
||||||
|
channels: [{ priceBookVersionId: '300', channelCode: 'EXX_RAIL_NY',
|
||||||
|
pricingTaxModes: ['TAX_INCLUDED'] }],
|
||||||
|
estimate: async () => ({ http: 200, body: { code: '200', data: {
|
||||||
|
priceBookVersionId: 'old-version', channelCode: 'OTHER', pricingTaxMode: 'TAX_EXCLUDED',
|
||||||
|
} }, requestId: 'request-drift', traceId: 'trace-drift' }),
|
||||||
|
})
|
||||||
|
assert.equal(result.priced, null)
|
||||||
|
assert.equal(result.attempts, 1)
|
||||||
|
assert.match(result.failures[0].mismatch, /version=old-version,channel=OTHER,tax=TAX_EXCLUDED/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pricing route bounds parallel probes and keeps deterministic candidate selection', async () => {
|
||||||
|
let active = 0; let maximumActive = 0
|
||||||
|
const result = await findPricedOrderRoute({
|
||||||
|
receiving: { id: '100' }, concurrency: 50,
|
||||||
|
destinations: Array.from({ length: 8 }, (_, index) => ({
|
||||||
|
id: String(200 + index), warehouseCode: `FBA${index}`, warehouseName: `FBA ${index}`,
|
||||||
|
})),
|
||||||
|
channels: [{ priceBookVersionId: '300', channelCode: 'CHANNEL', pricingTaxModes: ['TAX_INCLUDED'] }],
|
||||||
|
estimate: async (request) => {
|
||||||
|
active += 1; maximumActive = Math.max(maximumActive, active)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, request.destinationCode === 'FBA0' ? 5 : 1))
|
||||||
|
active -= 1
|
||||||
|
return { http: 200, body: { code: '200', data: {
|
||||||
|
priceBookVersionId: '300', channelCode: 'CHANNEL', pricingTaxMode: 'TAX_INCLUDED',
|
||||||
|
} } }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(maximumActive, 6)
|
||||||
|
assert.equal(result.priced.destination.warehouseCode, 'FBA0')
|
||||||
|
assert.equal(result.attempts, 6)
|
||||||
|
})
|
||||||
@@ -9,6 +9,10 @@ const BOXES = [
|
|||||||
{ boxType: 'EXTRA_LARGE', lengthCm: 60, widthCm: 50, heightCm: 50 },
|
{ boxType: 'EXTRA_LARGE', lengthCm: 60, widthCm: 50, heightCm: 50 },
|
||||||
]
|
]
|
||||||
const MEASURABLE_STATUSES = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
|
const MEASURABLE_STATUSES = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
|
||||||
|
const PRICING_PROBE_CONCURRENCY = 6
|
||||||
|
const ORDER_ESTIMATE_WEIGHT_KG = BOXES.reduce((total, _box, index) => total + 10 + index, 0)
|
||||||
|
const ORDER_ESTIMATE_VOLUME_CBM = BOXES.reduce((total, box) =>
|
||||||
|
total + box.lengthCm * box.widthCm * box.heightCm / 1_000_000, 0)
|
||||||
|
|
||||||
function id(value) { return value == null ? '' : String(value) }
|
function id(value) { return value == null ? '' : String(value) }
|
||||||
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
|
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
|
||||||
@@ -29,6 +33,77 @@ function tomorrow(days = 30) {
|
|||||||
return date.toISOString().slice(0, 10)
|
return date.toISOString().slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pricingEstimateRequest(receiving, channel, pricingTaxMode, destination) {
|
||||||
|
return {
|
||||||
|
originWarehouseId: receiving.id,
|
||||||
|
channelCode: channel.channelCode,
|
||||||
|
destinationCode: destination.warehouseCode,
|
||||||
|
destinationType: 'FBA',
|
||||||
|
destinationWarehouseId: destination.id,
|
||||||
|
destinationName: destination.warehouseName,
|
||||||
|
pricingTaxMode,
|
||||||
|
actualWeightKg: ORDER_ESTIMATE_WEIGHT_KG,
|
||||||
|
actualVolumeCbm: ORDER_ESTIMATE_VOLUME_CBM,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateMatches(response, candidate) {
|
||||||
|
const estimate = success(response) ? response.body?.data : null
|
||||||
|
return Boolean(estimate)
|
||||||
|
&& id(estimate.priceBookVersionId) === id(candidate.channel.priceBookVersionId)
|
||||||
|
&& id(estimate.channelCode) === id(candidate.channel.channelCode)
|
||||||
|
&& id(estimate.pricingTaxMode) === id(candidate.pricingTaxMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findPricedOrderRoute({ receiving, destinations, channels, estimate,
|
||||||
|
concurrency = PRICING_PROBE_CONCURRENCY }) {
|
||||||
|
const candidates = []
|
||||||
|
for (const channel of channels) {
|
||||||
|
if (!channel?.priceBookVersionId || !channel?.channelCode || !Array.isArray(channel.pricingTaxModes)) continue
|
||||||
|
for (const pricingTaxMode of channel.pricingTaxModes) {
|
||||||
|
if (!pricingTaxMode) continue
|
||||||
|
for (const destination of destinations) {
|
||||||
|
if (!destination?.id || !destination?.warehouseCode) continue
|
||||||
|
candidates.push({ channel, pricingTaxMode, destination,
|
||||||
|
request: pricingEstimateRequest(receiving, channel, pricingTaxMode, destination) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const failures = []
|
||||||
|
let lastResponse = null
|
||||||
|
const batchSize = Math.max(1, Math.min(Number(concurrency) || 1, PRICING_PROBE_CONCURRENCY))
|
||||||
|
let attempts = 0
|
||||||
|
for (let offset = 0; offset < candidates.length; offset += batchSize) {
|
||||||
|
const batch = candidates.slice(offset, offset + batchSize)
|
||||||
|
const results = await Promise.all(batch.map(async (candidate) => ({
|
||||||
|
candidate, response: await estimate(candidate.request),
|
||||||
|
})))
|
||||||
|
attempts += results.length
|
||||||
|
lastResponse = results.at(-1)?.response || lastResponse
|
||||||
|
const match = results.find(({ candidate, response }) => estimateMatches(response, candidate))
|
||||||
|
if (match) return { priced: match.candidate, response: match.response, attempts, failures }
|
||||||
|
for (const { candidate, response } of results) {
|
||||||
|
const returned = success(response) ? response.body?.data : null
|
||||||
|
failures.push({
|
||||||
|
tuple: `${candidate.channel.channelCode}/${candidate.pricingTaxMode}/${candidate.destination.warehouseCode}`,
|
||||||
|
http: response?.http ?? 'missing', code: response?.body?.code ?? 'missing',
|
||||||
|
requestId: response?.requestId ?? 'missing', traceId: response?.traceId ?? 'missing',
|
||||||
|
mismatch: returned
|
||||||
|
? `version=${id(returned.priceBookVersionId) || 'missing'},channel=${id(returned.channelCode) || 'missing'},tax=${id(returned.pricingTaxMode) || 'missing'}`
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { priced: null, response: lastResponse, attempts, failures }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pricingFailureEvidence(result) {
|
||||||
|
const describe = (failure) => failure
|
||||||
|
? `${failure.tuple}[HTTP=${failure.http},code=${failure.code},requestId=${failure.requestId},traceId=${failure.traceId}${failure.mismatch ? `,response=${failure.mismatch}` : ''}]`
|
||||||
|
: 'missing'
|
||||||
|
return `没有可计价组合;attempts=${result.attempts}, first=${describe(result.failures[0])}, last=${describe(result.failures.at(-1))}`
|
||||||
|
}
|
||||||
|
|
||||||
async function request(context, method, path, body, token, extraHeaders = {}) {
|
async function request(context, method, path, body, token, extraHeaders = {}) {
|
||||||
const headers = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...extraHeaders }
|
const headers = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...extraHeaders }
|
||||||
let payload = body
|
let payload = body
|
||||||
@@ -96,19 +171,32 @@ async function loadMasterData(context, merchantToken) {
|
|||||||
const fbaPage = context.apiSuccess(MODULE, 'Client 查询 FBA 目的仓', fbaResponse)
|
const fbaPage = context.apiSuccess(MODULE, 'Client 查询 FBA 目的仓', fbaResponse)
|
||||||
context.assert(MODULE, '存在可用 FBA 目的仓', fbaResponse, records(fbaPage).length > 0,
|
context.assert(MODULE, '存在可用 FBA 目的仓', fbaResponse, records(fbaPage).length > 0,
|
||||||
`count=${records(fbaPage).length}`)
|
`count=${records(fbaPage).length}`)
|
||||||
const receiving = records(domesticPage)[0]; const destination = records(fbaPage)[0]
|
const receiving = records(domesticPage)[0]
|
||||||
if (!receiving || !destination) throw new Error('缺少创建测试订单所需的收货仓或目的仓')
|
const destinations = records(fbaPage)
|
||||||
|
if (!receiving || destinations.length === 0) throw new Error('缺少创建测试订单所需的收货仓或目的仓')
|
||||||
|
|
||||||
const channelResponse = await request(context, 'GET',
|
const channelResponse = await request(context, 'GET',
|
||||||
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`,
|
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`,
|
||||||
undefined, merchantToken)
|
undefined, merchantToken)
|
||||||
const channels = context.apiSuccess(MODULE, 'Client 查询可用计费渠道', channelResponse)
|
const channels = context.apiSuccess(MODULE, 'Client 查询可用计费渠道', channelResponse)
|
||||||
const channel = Array.isArray(channels)
|
const priceableChannels = Array.isArray(channels)
|
||||||
? channels.find((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0)
|
? channels.filter((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0)
|
||||||
: null
|
: []
|
||||||
context.assert(MODULE, '存在带计价税制的可用渠道', channelResponse, Boolean(channel),
|
context.assert(MODULE, '存在带计价税制的可用渠道', channelResponse, priceableChannels.length > 0,
|
||||||
`count=${Array.isArray(channels) ? channels.length : 'n/a'}`)
|
`count=${Array.isArray(channels) ? channels.length : 'n/a'}`)
|
||||||
if (!channel) throw new Error('缺少创建测试订单所需的可用渠道')
|
if (priceableChannels.length === 0) throw new Error('缺少创建测试订单所需的可用渠道')
|
||||||
|
|
||||||
|
const pricingResult = await findPricedOrderRoute({
|
||||||
|
receiving, destinations, channels: priceableChannels,
|
||||||
|
estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken),
|
||||||
|
})
|
||||||
|
const priced = pricingResult.priced
|
||||||
|
context.addStep(MODULE, 'Client 选择具有真实价格覆盖的渠道和目的仓', pricingResult.response, Boolean(priced),
|
||||||
|
priced
|
||||||
|
? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination.warehouseCode}, attempts=${pricingResult.attempts}`
|
||||||
|
: pricingFailureEvidence(pricingResult),
|
||||||
|
CONTRACT_OPTIONS)
|
||||||
|
if (!priced) throw new Error('当前起运仓、渠道、报价税制和FBA目的仓没有可计价组合')
|
||||||
|
|
||||||
const partnerResponse = await request(context, 'GET',
|
const partnerResponse = await request(context, 'GET',
|
||||||
'/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken)
|
'/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken)
|
||||||
@@ -116,7 +204,8 @@ async function loadMasterData(context, merchantToken) {
|
|||||||
const partner = Array.isArray(partners)
|
const partner = Array.isArray(partners)
|
||||||
? partners.find((item) => item.status === 'ACTIVE' && item.types?.includes?.('SHIPPER'))
|
? partners.find((item) => item.status === 'ACTIVE' && item.types?.includes?.('SHIPPER'))
|
||||||
: null
|
: null
|
||||||
return { receiving, destination, channel, partner }
|
return { receiving, destination: priced.destination, channel: priced.channel,
|
||||||
|
pricingTaxMode: priced.pricingTaxMode, partner }
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPayload(context, profile, master, productImageFileId) {
|
function buildPayload(context, profile, master, productImageFileId) {
|
||||||
@@ -135,7 +224,7 @@ function buildPayload(context, profile, master, productImageFileId) {
|
|||||||
receivingWarehouseName: master.receiving.warehouseName,
|
receivingWarehouseName: master.receiving.warehouseName,
|
||||||
channelCode: master.channel.channelCode, channelName: master.channel.channelName,
|
channelCode: master.channel.channelCode, channelName: master.channel.channelName,
|
||||||
carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION',
|
carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION',
|
||||||
pricingTaxMode: master.channel.pricingTaxModes[0], containerMode: 'LCL',
|
pricingTaxMode: master.pricingTaxMode, containerMode: 'LCL',
|
||||||
customsInspection: false, destinationType: 'FBA',
|
customsInspection: false, destinationType: 'FBA',
|
||||||
amazonWarehouseCode: master.destination.warehouseCode,
|
amazonWarehouseCode: master.destination.warehouseCode,
|
||||||
shipmentId, referenceId,
|
shipmentId, referenceId,
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export async function run(context) {
|
|||||||
expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128),
|
expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128),
|
||||||
})
|
})
|
||||||
context.addStep(MODULE, '异渠道入库单拒绝装入当前柜次', foreignAssignResponse,
|
context.addStep(MODULE, '异渠道入库单拒绝装入当前柜次', foreignAssignResponse,
|
||||||
foreignAssignResponse.http === 200 && ['A0400', '400'].includes(codeOf(foreignAssignResponse)),
|
foreignAssignResponse.http === 200 && ['A0422', '422'].includes(codeOf(foreignAssignResponse)),
|
||||||
`HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS)
|
`HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS)
|
||||||
|
|
||||||
const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
|
const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
|
||||||
@@ -275,7 +275,7 @@ export async function run(context) {
|
|||||||
version: Number(replaced?.version), reason: `${context.marker}-clear-container-no`,
|
version: Number(replaced?.version), reason: `${context.marker}-clear-container-no`,
|
||||||
})
|
})
|
||||||
context.addStep(MODULE, 'PDA 任务创建后清空真实柜号失败', clearContainerNoResponse,
|
context.addStep(MODULE, 'PDA 任务创建后清空真实柜号失败', clearContainerNoResponse,
|
||||||
clearContainerNoResponse.http === 200 && ['A0400', '400'].includes(codeOf(clearContainerNoResponse)),
|
clearContainerNoResponse.http === 200 && ['A0409', '409'].includes(codeOf(clearContainerNoResponse)),
|
||||||
`HTTP=${clearContainerNoResponse.http}, code=${codeOf(clearContainerNoResponse)}`, OPTIONS)
|
`HTTP=${clearContainerNoResponse.http}, code=${codeOf(clearContainerNoResponse)}`, OPTIONS)
|
||||||
|
|
||||||
const replacedTaskResponse = await context.request('GET',
|
const replacedTaskResponse = await context.request('GET',
|
||||||
|
|||||||
@@ -5,13 +5,19 @@ const CONTAINERS = '/api/platform/v1/containers'
|
|||||||
function id(value) { return value == null ? '' : String(value) }
|
function id(value) { return value == null ? '' : String(value) }
|
||||||
|
|
||||||
export async function run(context) {
|
export async function run(context) {
|
||||||
const fixture = context.createdResources?.phase3ContainerLoading
|
const loadingRequested = context.options?.modules?.includes('phase3-loading') === true
|
||||||
|
const fixture = loadingRequested
|
||||||
|
? context.createdResources?.phase3Loading
|
||||||
|
: context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading
|
||||||
const containerId = id(fixture?.containerBookingId)
|
const containerId = id(fixture?.containerBookingId)
|
||||||
const planId = id(fixture?.loadingPlanId)
|
const planId = id(fixture?.loadingPlanId)
|
||||||
const warehouseId = id(fixture?.warehouseId)
|
const warehouseId = id(fixture?.warehouseId)
|
||||||
if (!containerId || !planId || !warehouseId || fixture?.marker !== context.marker) {
|
const markerMatches = fixture?.marker === context.marker
|
||||||
|
|| fixture?.marker === `${context.marker}-CAB`
|
||||||
|
|| fixture?.sourceMarker === `${context.marker}-CAB`
|
||||||
|
if (!containerId || !planId || !warehouseId || !markerMatches) {
|
||||||
context.addStep(MODULE, '复用智能装柜产生的货柜 overview fixture', null, false,
|
context.addStep(MODULE, '复用智能装柜产生的货柜 overview fixture', null, false,
|
||||||
`containerBookingId=${containerId || 'missing'}, loadingPlanId=${planId || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
|
`source=${loadingRequested ? 'phase3Loading-required' : 'available-container-fixture'}, containerBookingId=${containerId || 'missing'}, loadingPlanId=${planId || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,12 +37,15 @@ export async function run(context) {
|
|||||||
`events=${timeline.length}`, OPTIONS)
|
`events=${timeline.length}`, OPTIONS)
|
||||||
|
|
||||||
if (overview?.pda) {
|
if (overview?.pda) {
|
||||||
|
const expectsPhotos = fixture === context.createdResources?.phase3Loading
|
||||||
|
&& Array.isArray(fixture?.evidenceTypes) && fixture.evidenceTypes.length > 0
|
||||||
context.assert(MODULE, 'overview PDA 摘要字段完整', overviewResponse,
|
context.assert(MODULE, 'overview PDA 摘要字段完整', overviewResponse,
|
||||||
['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount']
|
['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount']
|
||||||
.every((field) => overview.pda[field] !== undefined)
|
.every((field) => overview.pda[field] !== undefined)
|
||||||
&& Array.isArray(overview.pda.photoCounts)
|
&& Array.isArray(overview.pda.photoCounts)
|
||||||
&& Array.isArray(overview.pda.photos)
|
&& Array.isArray(overview.pda.photos)
|
||||||
&& Array.isArray(overview.pda.exceptionReasons),
|
&& Array.isArray(overview.pda.exceptionReasons)
|
||||||
|
&& (!expectsPhotos || overview.pda.photos.length > 0),
|
||||||
`taskId=${id(overview.pda.taskId)}, photos=${overview.pda.photos.length}`, OPTIONS)
|
`taskId=${id(overview.pda.taskId)}, photos=${overview.pda.photos.length}`, OPTIONS)
|
||||||
for (const [index, photo] of overview.pda.photos.entries()) {
|
for (const [index, photo] of overview.pda.photos.entries()) {
|
||||||
context.assert(MODULE, `overview PDA 照片摘要 ${index + 1} 字段完整`, overviewResponse,
|
context.assert(MODULE, `overview PDA 照片摘要 ${index + 1} 字段完整`, overviewResponse,
|
||||||
|
|||||||
@@ -117,7 +117,38 @@ export async function run(context) {
|
|||||||
const warehouseId = id(initial?.summary?.warehouseId)
|
const warehouseId = id(initial?.summary?.warehouseId)
|
||||||
const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading'
|
const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading'
|
||||||
const pdaBase = '/api/platform/v1/first-leg/pda-loading'
|
const pdaBase = '/api/platform/v1/first-leg/pda-loading'
|
||||||
const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`)
|
const bookingDraft = {
|
||||||
|
customerId: tenantId, customerName: initial?.summary?.customerName,
|
||||||
|
warehouseId, warehouseName: initial?.summary?.warehouseName,
|
||||||
|
channelCode: initial?.summary?.channelCode, channelName: initial?.summary?.channelName,
|
||||||
|
shippingCompanyName: `P3-SHIPPING-${suffix}`, bookingAgentName: `P3-AGENT-${suffix}`,
|
||||||
|
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
|
||||||
|
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
||||||
|
remark: context.marker,
|
||||||
|
parties: [{ partyType: 'SHIPPER', partyName: `P3-SHIPPER-${suffix}` },
|
||||||
|
{ partyType: 'CONSIGNEE', partyName: `P3-CONSIGNEE-${suffix}` },
|
||||||
|
{ partyType: 'NOTIFY_PARTY', partyName: `P3-NOTIFY-${suffix}` }],
|
||||||
|
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100,
|
||||||
|
maxWeightKg: 100000, quantity: 1 }],
|
||||||
|
}
|
||||||
|
const bookingCreateResponse = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
|
||||||
|
{ expectedVersion: 0, data: bookingDraft },
|
||||||
|
{ 'Idempotency-Key': `${context.marker}-p3-booking-create`.slice(0, 128) })
|
||||||
|
const bookingCreated = context.apiSuccess(MODULE, '创建带渠道仓库快照的三期订舱', bookingCreateResponse, '', OPTIONS)
|
||||||
|
const bookingId = id(bookingCreated?.oceanBooking?.id)
|
||||||
|
if (!bookingId) return
|
||||||
|
const bookingSubmitResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/submit`,
|
||||||
|
{ expectedVersion: Number(bookingCreated.oceanBooking.version), reason: context.marker },
|
||||||
|
{ 'Idempotency-Key': `${context.marker}-p3-booking-submit`.slice(0, 128) })
|
||||||
|
const bookingSubmitted = context.apiSuccess(MODULE, '提交三期订舱', bookingSubmitResponse, '', OPTIONS)
|
||||||
|
const bookingConfirmResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/confirm`,
|
||||||
|
{ expectedVersion: Number(bookingSubmitted?.oceanBooking?.version), reason: context.marker },
|
||||||
|
{ 'Idempotency-Key': `${context.marker}-p3-booking-confirm`.slice(0, 128) })
|
||||||
|
const bookingConfirmed = context.apiSuccess(MODULE, '确认订舱并自动生成三期柜次', bookingConfirmResponse, '', OPTIONS)
|
||||||
|
const containerBookingId = id(bookingConfirmed?.containerAllocations?.[0]?.containerBookingId)
|
||||||
|
if (!containerBookingId) return
|
||||||
|
|
||||||
|
const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS)
|
const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS)
|
||||||
const candidate = Array.isArray(candidates) ? candidates.find((item) => id(item.inboundId) === inboundId
|
const candidate = Array.isArray(candidates) ? candidates.find((item) => id(item.inboundId) === inboundId
|
||||||
&& Number(item.availablePackageCount) > 0) : null
|
&& Number(item.availablePackageCount) > 0) : null
|
||||||
@@ -128,21 +159,6 @@ export async function run(context) {
|
|||||||
if (!candidate?.inboundDetailId || !warehouseId) return
|
if (!candidate?.inboundDetailId || !warehouseId) return
|
||||||
|
|
||||||
const containerNo = `P3B-${suffix}`
|
const containerNo = `P3B-${suffix}`
|
||||||
const containerResponse = await request(context, 'POST', '/api/platform/v1/containers', {
|
|
||||||
containerNo, cabinetNo: `P3BI-${suffix}`, warehouseId,
|
|
||||||
warehouseName: initial?.summary?.warehouseName || null, billOfLadingNo: `P3BL-${suffix}`,
|
|
||||||
vesselName: `P3-BRIDGE-${suffix}`, voyageNo: `P3V-${suffix}`, containerType: '40HQ',
|
|
||||||
shippingCompanyName: `P3-SHIPPING-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳',
|
|
||||||
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
|
||||||
destinationWarehouseCode: candidate.destinationWarehouseCode || null,
|
|
||||||
destinationWarehouseName: candidate.destinationWarehouseName || null,
|
|
||||||
capacityCbm: 100, maxWeightKg: 100000, loadingAt: '2026-09-02T08:00:00',
|
|
||||||
estimatedSailingAt: '2026-09-03T08:00:00', estimatedPortArrivalAt: '2026-09-20T08:00:00',
|
|
||||||
estimatedWarehouseArrivalAt: '2026-09-21T08:00:00',
|
|
||||||
})
|
|
||||||
const container = context.apiSuccess(MODULE, '创建与入库预装串联的柜次', containerResponse, '', OPTIONS)
|
|
||||||
const containerBookingId = id(container?.id)
|
|
||||||
if (!containerBookingId) return
|
|
||||||
|
|
||||||
const loadingPlanResponse = await request(context, 'POST', `${smartLoadingBase}/plans`, {
|
const loadingPlanResponse = await request(context, 'POST', `${smartLoadingBase}/plans`, {
|
||||||
bookingId: containerBookingId, remark: context.marker,
|
bookingId: containerBookingId, remark: context.marker,
|
||||||
@@ -203,6 +219,20 @@ export async function run(context) {
|
|||||||
&& Number(pushedLoadingPlan?.version) === Number(link.loadingPlanVersion) + 1,
|
&& Number(pushedLoadingPlan?.version) === Number(link.loadingPlanVersion) + 1,
|
||||||
`status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
|
`status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const containerBeforeTransportResponse = await context.request('GET',
|
||||||
|
`/api/platform/v1/containers/${containerBookingId}`)
|
||||||
|
const containerBeforeTransport = context.apiSuccess(MODULE, '读取补真实柜号前柜次版本',
|
||||||
|
containerBeforeTransportResponse, '', OPTIONS)
|
||||||
|
const transportResponse = await request(context, 'PATCH',
|
||||||
|
`/api/platform/v1/containers/${containerBookingId}/transport`, {
|
||||||
|
containerNo, voyageNo: `P3V-${suffix}`, version: Number(containerBeforeTransport?.version),
|
||||||
|
reason: `${context.marker}-bind-container-no`,
|
||||||
|
})
|
||||||
|
const transported = context.apiSuccess(MODULE, '补真实柜号并开放三期 PDA 任务', transportResponse, '', OPTIONS)
|
||||||
|
context.assert(MODULE, '真实柜号绑定到稳定柜次且计划身份不变', transportResponse,
|
||||||
|
transported?.containerNo === containerNo && id(transported?.id) === containerBookingId,
|
||||||
|
`containerNo=${transported?.containerNo || 'missing'}, containerBookingId=${id(transported?.id) || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
const blockedBeforePreloadConfirm = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
const blockedBeforePreloadConfirm = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||||
{ expectedVersion: initialVersion, remark: 'must confirm preload first' },
|
{ expectedVersion: initialVersion, remark: 'must confirm preload first' },
|
||||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-preload-confirm`.slice(0, 128) })
|
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-preload-confirm`.slice(0, 128) })
|
||||||
@@ -211,7 +241,7 @@ export async function run(context) {
|
|||||||
`HTTP=${blockedBeforePreloadConfirm.http}, code=${codeOf(blockedBeforePreloadConfirm)}`, OPTIONS)
|
`HTTP=${blockedBeforePreloadConfirm.http}, code=${codeOf(blockedBeforePreloadConfirm)}`, OPTIONS)
|
||||||
|
|
||||||
const canonicalTaskResponse = await context.request('GET',
|
const canonicalTaskResponse = await context.request('GET',
|
||||||
`${pdaBase}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
const canonicalTask = context.apiSuccess(MODULE, '读取柜次级 PDA 唯一事实任务', canonicalTaskResponse, '', OPTIONS)
|
const canonicalTask = context.apiSuccess(MODULE, '读取柜次级 PDA 唯一事实任务', canonicalTaskResponse, '', OPTIONS)
|
||||||
const canonicalTaskId = id(canonicalTask?.id)
|
const canonicalTaskId = id(canonicalTask?.id)
|
||||||
const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId)
|
const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId)
|
||||||
@@ -241,8 +271,11 @@ export async function run(context) {
|
|||||||
const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS)
|
const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS)
|
||||||
const inventoryInbound = inventory?.inbounds?.find?.((item) => id(item.inboundId) === inboundId)
|
const inventoryInbound = inventory?.inbounds?.find?.((item) => id(item.inboundId) === inboundId)
|
||||||
const inventoryIdentityVerified = id(inventory?.taskId) === canonicalTaskId
|
const inventoryIdentityVerified = id(inventory?.taskId) === canonicalTaskId
|
||||||
|
&& id(inventory?.loadingPlanId) === loadingPlanId
|
||||||
|
&& id(inventory?.containerBookingId) === containerBookingId
|
||||||
&& Number(inventory?.taskVersion) === Number(claimed?.version)
|
&& Number(inventory?.taskVersion) === Number(claimed?.version)
|
||||||
&& inventory?.containerNo === containerNo && id(inventory?.warehouseId) === warehouseId
|
&& id(inventory?.cabinetNo) && inventory?.currentContainerNo === containerNo
|
||||||
|
&& id(inventory?.warehouseId) === warehouseId
|
||||||
&& Boolean(inventory?.capturedAt) && typeof inventory?.snapshotToken === 'string'
|
&& Boolean(inventory?.capturedAt) && typeof inventory?.snapshotToken === 'string'
|
||||||
&& inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1
|
&& inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1
|
||||||
&& (inventoryInbound?.resources || []).every((resource) => id(resource?.reservationId)
|
&& (inventoryInbound?.resources || []).every((resource) => id(resource?.reservationId)
|
||||||
@@ -269,7 +302,7 @@ export async function run(context) {
|
|||||||
const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch`
|
const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch`
|
||||||
const batchKey = `${context.marker}-bridge-batch`.slice(0, 128)
|
const batchKey = `${context.marker}-bridge-batch`.slice(0, 128)
|
||||||
const batchPayload = {
|
const batchPayload = {
|
||||||
warehouseId, containerNo,
|
warehouseId, loadingPlanId, containerBookingId,
|
||||||
expectedVersion: Number(claimed?.version),
|
expectedVersion: Number(claimed?.version),
|
||||||
inventorySnapshotToken: inventory.snapshotToken,
|
inventorySnapshotToken: inventory.snapshotToken,
|
||||||
items: [{
|
items: [{
|
||||||
@@ -447,36 +480,50 @@ export async function run(context) {
|
|||||||
const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload,
|
const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload,
|
||||||
{ 'Idempotency-Key': batchKey })
|
{ 'Idempotency-Key': batchKey })
|
||||||
const pickedCanonical = context.apiSuccess(MODULE, '原子提交柜次 PDA 整批拣货', canonicalPick, '', OPTIONS)
|
const pickedCanonical = context.apiSuccess(MODULE, '原子提交柜次 PDA 整批拣货', canonicalPick, '', OPTIONS)
|
||||||
const directPdaCompletion = pickedCanonical?.taskStatus === 'COMPLETED'
|
const awaitingReview = pickedCanonical?.taskStatus === 'LOADING'
|
||||||
&& pickedCanonical?.workflowStatus === 'COMPLETED'
|
&& pickedCanonical?.workflowStatus === 'AWAITING_REVIEW'
|
||||||
context.assert(MODULE, 'PDA 原子批次成功并推进任务终态', canonicalPick,
|
context.assert(MODULE, 'PDA 原子批次完成拣货并等待人工确认', canonicalPick,
|
||||||
directPdaCompletion && pickedCanonical?.items?.every?.((item) => item.itemStatus !== 'PENDING'),
|
awaitingReview && pickedCanonical?.items?.every?.((item) => item.itemStatus !== 'PENDING'),
|
||||||
`taskStatus=${pickedCanonical?.taskStatus || 'missing'}, workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
|
`taskStatus=${pickedCanonical?.taskStatus || 'missing'}, workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
const canonicalBatchReplay = await request(context, 'POST', batchPath, effectiveBatchPayload,
|
const canonicalBatchReplay = await request(context, 'POST', batchPath, effectiveBatchPayload,
|
||||||
{ 'Idempotency-Key': batchKey })
|
{ 'Idempotency-Key': batchKey })
|
||||||
const atomicBatchReplayVerified = canonicalBatchReplay.http === 200 && codeOf(canonicalBatchReplay) === '200'
|
const atomicBatchReplayVerified = canonicalBatchReplay.http === 200 && codeOf(canonicalBatchReplay) === '200'
|
||||||
&& id(canonicalBatchReplay.body?.data?.id) === canonicalTaskId
|
&& id(canonicalBatchReplay.body?.data?.id) === canonicalTaskId
|
||||||
&& canonicalBatchReplay.body?.data?.taskStatus === 'COMPLETED'
|
&& canonicalBatchReplay.body?.data?.taskStatus === 'LOADING'
|
||||||
|
&& canonicalBatchReplay.body?.data?.workflowStatus === 'AWAITING_REVIEW'
|
||||||
&& Number(canonicalBatchReplay.body?.data?.version) === Number(pickedCanonical?.version)
|
&& Number(canonicalBatchReplay.body?.data?.version) === Number(pickedCanonical?.version)
|
||||||
context.assert(MODULE, 'PDA 原子批次同参同键重放返回同一终态', canonicalBatchReplay,
|
context.assert(MODULE, 'PDA 原子批次同参同键重放保持待人工确认', canonicalBatchReplay,
|
||||||
atomicBatchReplayVerified,
|
atomicBatchReplayVerified,
|
||||||
`HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS)
|
`HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const completeResponse = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, {
|
||||||
|
expectedTaskVersion: Number(pickedCanonical?.version),
|
||||||
|
operationId: `${context.marker}-bridge-complete`.slice(0, 128), remark: context.marker,
|
||||||
|
})
|
||||||
|
const completedCanonical = context.apiSuccess(MODULE, '人工确认柜次 PDA 装柜完成', completeResponse, '', OPTIONS)
|
||||||
|
const directPdaCompletion = completedCanonical?.taskStatus === 'COMPLETED'
|
||||||
|
&& completedCanonical?.workflowStatus === 'COMPLETED'
|
||||||
|
context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completeResponse,
|
||||||
|
directPdaCompletion && completedCanonical?.items?.every?.((item) => item.itemStatus === 'PICKED'),
|
||||||
|
`taskStatus=${completedCanonical?.taskStatus || 'missing'}, workflow=${completedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
const completionOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
|
const completionOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
|
||||||
const completionOperations = context.apiSuccess(MODULE, '回查原子 Batch 完成审计', completionOperationsResponse, '', OPTIONS)
|
const completionOperations = context.apiSuccess(MODULE, '回查原子 Batch 完成审计', completionOperationsResponse, '', OPTIONS)
|
||||||
context.assert(MODULE, '合法 Batch 完成后保留 CLAIM/BATCH 操作者审计且无伪造 REVIEW', completionOperationsResponse,
|
context.assert(MODULE, '人工完成后保留 CLAIM/BATCH/COMPLETE_LOADING 审计', completionOperationsResponse,
|
||||||
Array.isArray(completionOperations)
|
Array.isArray(completionOperations)
|
||||||
&& completionOperations.some((item) => item.actionType === 'CLAIM'
|
&& completionOperations.some((item) => item.actionType === 'CLAIM'
|
||||||
&& id(item.operatorId) && Boolean(item.createdAt))
|
&& id(item.operatorId) && Boolean(item.createdAt))
|
||||||
&& completionOperations.some((item) => item.actionType === 'BATCH'
|
&& completionOperations.some((item) => item.actionType === 'BATCH'
|
||||||
&& id(item.operatorId) && Boolean(item.createdAt))
|
&& id(item.operatorId) && Boolean(item.createdAt))
|
||||||
|
&& completionOperations.some((item) => item.actionType === 'COMPLETE_LOADING'
|
||||||
|
&& id(item.operatorId) && Boolean(item.createdAt))
|
||||||
&& !completionOperations.some((item) => item.actionType === 'REVIEW'),
|
&& !completionOperations.some((item) => item.actionType === 'REVIEW'),
|
||||||
`actions=${Array.isArray(completionOperations) ? completionOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
|
`actions=${Array.isArray(completionOperations) ? completionOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
const pickedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
const pickedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||||
const pickedPlan = context.apiSuccess(MODULE, '首笔真实拣货后回查柜次计划', pickedPlanResponse, '', OPTIONS)
|
const pickedPlan = context.apiSuccess(MODULE, '首笔真实拣货后回查柜次计划', pickedPlanResponse, '', OPTIONS)
|
||||||
context.assert(MODULE, '首笔真实拣货触发装柜状态并由单明细批次直接完成', pickedPlanResponse,
|
context.assert(MODULE, '人工确认后柜次装柜计划完成', pickedPlanResponse,
|
||||||
directPdaCompletion && pickedPlan?.status === 'COMPLETED',
|
directPdaCompletion && pickedPlan?.status === 'COMPLETED',
|
||||||
`status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS)
|
`status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
@@ -517,11 +564,10 @@ export async function run(context) {
|
|||||||
`status=${loading?.summary?.processStatus || 'missing'}, version=${loading?.summary?.version ?? 'missing'}`, OPTIONS)
|
`status=${loading?.summary?.processStatus || 'missing'}, version=${loading?.summary?.version ?? 'missing'}`, OPTIONS)
|
||||||
if (!Number.isInteger(loadingVersion)) return
|
if (!Number.isInteger(loadingVersion)) return
|
||||||
|
|
||||||
// The deployed settlement contract completes a one-item task during pick. A
|
// COMPLETE_LOADING is the only positive completion action; REVIEW is a
|
||||||
// review call is therefore a negative compatibility assertion, never a second
|
// negative compatibility assertion after the task is already complete.
|
||||||
// positive action that can poison the marker fixture with A0409.
|
|
||||||
const canonicalReview = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/review`, {
|
const canonicalReview = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/review`, {
|
||||||
expectedTaskVersion: Number(pickedCanonical?.version),
|
expectedTaskVersion: Number(completedCanonical?.version),
|
||||||
operationId: `${context.marker}-bridge-review`.slice(0, 128), remark: context.marker,
|
operationId: `${context.marker}-bridge-review`.slice(0, 128), remark: context.marker,
|
||||||
})
|
})
|
||||||
context.addStep(MODULE, '柜次 PDA 已完成后拒绝重复复核', canonicalReview,
|
context.addStep(MODULE, '柜次 PDA 已完成后拒绝重复复核', canonicalReview,
|
||||||
@@ -530,55 +576,8 @@ export async function run(context) {
|
|||||||
: canonicalReview.http === 200 && codeOf(canonicalReview) === '200',
|
: canonicalReview.http === 200 && codeOf(canonicalReview) === '200',
|
||||||
`HTTP=${canonicalReview.http}, code=${codeOf(canonicalReview)}`, OPTIONS)
|
`HTTP=${canonicalReview.http}, code=${codeOf(canonicalReview)}`, OPTIONS)
|
||||||
|
|
||||||
const forgedPickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
|
context.addStep(MODULE, '柜次 PDA 作为现场作业唯一事实源', null, true,
|
||||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: 'forged-client-count' },
|
'本场景不调用旧入库级 picking/start 写接口,避免客户端汇总数量覆盖柜次任务事实', OPTIONS)
|
||||||
{ 'Idempotency-Key': `${context.marker}-p3-picking-forged-start`.slice(0, 128) })
|
|
||||||
context.addStep(MODULE, '入库级 PDA 汇总拒绝客户端伪造数量', forgedPickingStart, failure(forgedPickingStart),
|
|
||||||
`HTTP=${forgedPickingStart.http}, code=${codeOf(forgedPickingStart)}`, OPTIONS)
|
|
||||||
|
|
||||||
const pickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
|
|
||||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 1, remark: context.marker },
|
|
||||||
{ 'Idempotency-Key': `${context.marker}-p3-picking-start`.slice(0, 128) })
|
|
||||||
const pickingStarted = directPdaCompletion
|
|
||||||
? null
|
|
||||||
: context.apiSuccess(MODULE, '开始本轮 PDA 拣货任务', pickingStart, '', OPTIONS)
|
|
||||||
context.addStep(MODULE, directPdaCompletion
|
|
||||||
? '柜次 PDA 已完成后拒绝旧入库级启动入口' : '开始本轮 PDA 拣货任务', pickingStart,
|
|
||||||
directPdaCompletion
|
|
||||||
? pickingStart.http === 200 && codeOf(pickingStart) === 'A0409'
|
|
||||||
: Boolean(pickingStarted),
|
|
||||||
`HTTP=${pickingStart.http}, code=${codeOf(pickingStart)}, status=${pickingStarted?.status || 'n/a'}`, OPTIONS)
|
|
||||||
if (!directPdaCompletion) {
|
|
||||||
context.assert(MODULE, 'PDA 拣货任务处于 IN_PROGRESS 且计划数量正确', pickingStart,
|
|
||||||
pickingStarted?.status === 'IN_PROGRESS' && Number(pickingStarted?.version) === 0
|
|
||||||
&& Number(pickingStarted?.expectedCount) === 1 && Number(pickingStarted?.pickedCount) === 1,
|
|
||||||
`status=${pickingStarted?.status || 'missing'}, version=${pickingStarted?.version ?? 'missing'}`, OPTIONS)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickingMismatch = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
|
||||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: context.marker },
|
|
||||||
{ 'Idempotency-Key': `${context.marker}-p3-picking-mismatch`.slice(0, 128) })
|
|
||||||
context.addStep(MODULE, 'PDA 拣货数量不一致时拒绝完成', pickingMismatch, failure(pickingMismatch),
|
|
||||||
`HTTP=${pickingMismatch.http}, code=${codeOf(pickingMismatch)}`, OPTIONS)
|
|
||||||
|
|
||||||
const pickingComplete = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
|
||||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 1, remark: context.marker },
|
|
||||||
{ 'Idempotency-Key': `${context.marker}-p3-picking-complete`.slice(0, 128) })
|
|
||||||
const pickingCompleted = directPdaCompletion
|
|
||||||
? null
|
|
||||||
: context.apiSuccess(MODULE, '完成本轮 PDA 拣货复核', pickingComplete, '', OPTIONS)
|
|
||||||
context.addStep(MODULE, directPdaCompletion
|
|
||||||
? '柜次 PDA 已完成后拒绝旧入库级完成入口' : '完成本轮 PDA 拣货复核', pickingComplete,
|
|
||||||
directPdaCompletion
|
|
||||||
? pickingComplete.http === 200 && codeOf(pickingComplete) === 'A0409'
|
|
||||||
: Boolean(pickingCompleted),
|
|
||||||
`HTTP=${pickingComplete.http}, code=${codeOf(pickingComplete)}, status=${pickingCompleted?.status || 'n/a'}`, OPTIONS)
|
|
||||||
if (!directPdaCompletion) {
|
|
||||||
context.assert(MODULE, 'PDA 拣货任务 COMPLETED 且数量一致', pickingComplete,
|
|
||||||
pickingCompleted?.status === 'COMPLETED' && Number(pickingCompleted?.version) === 1
|
|
||||||
&& Number(pickingCompleted?.expectedCount) === 1 && Number(pickingCompleted?.pickedCount) === 1,
|
|
||||||
`status=${pickingCompleted?.status || 'missing'}, version=${pickingCompleted?.version ?? 'missing'}`, OPTIONS)
|
|
||||||
}
|
|
||||||
|
|
||||||
const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
|
const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
|
||||||
const evidenceItems = []
|
const evidenceItems = []
|
||||||
@@ -784,8 +783,15 @@ export async function run(context) {
|
|||||||
&& evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`),
|
&& evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`),
|
||||||
`evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS)
|
`evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS)
|
||||||
|
|
||||||
|
const beforeHardGateResponse = await context.request('GET', detailPath)
|
||||||
|
const beforeHardGate = context.apiSuccess(MODULE, '读取 LOADED 硬门禁调用前即时版本', beforeHardGateResponse, '', OPTIONS)
|
||||||
|
const beforeHardGateVersion = Number(beforeHardGate?.summary?.version)
|
||||||
|
context.assert(MODULE, 'LOADED 硬门禁即时基线状态和版本有效', beforeHardGateResponse,
|
||||||
|
beforeHardGate?.summary?.processStatus === 'LOADING' && Number.isInteger(beforeHardGateVersion),
|
||||||
|
`status=${beforeHardGate?.summary?.processStatus || 'missing'}, version=${beforeHardGate?.summary?.version ?? 'missing'}`, OPTIONS)
|
||||||
|
if (!Number.isInteger(beforeHardGateVersion)) return
|
||||||
const blockedBeforeDownstream = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
const blockedBeforeDownstream = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||||
{ expectedVersion: loadingVersion, remark: `${context.marker}-before-downstream` },
|
{ expectedVersion: beforeHardGateVersion, remark: `${context.marker}-before-downstream` },
|
||||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-downstream`.slice(0, 128) })
|
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-downstream`.slice(0, 128) })
|
||||||
context.addStep(MODULE, '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', blockedBeforeDownstream,
|
context.addStep(MODULE, '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', blockedBeforeDownstream,
|
||||||
failure(blockedBeforeDownstream),
|
failure(blockedBeforeDownstream),
|
||||||
@@ -794,7 +800,7 @@ export async function run(context) {
|
|||||||
const blockedDetail = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的入库状态', blockedDetailResponse, '', OPTIONS)
|
const blockedDetail = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的入库状态', blockedDetailResponse, '', OPTIONS)
|
||||||
context.assert(MODULE, 'LOADED 硬门禁拒绝后状态和版本零写入', blockedDetailResponse,
|
context.assert(MODULE, 'LOADED 硬门禁拒绝后状态和版本零写入', blockedDetailResponse,
|
||||||
blockedDetail?.summary?.processStatus === 'LOADING'
|
blockedDetail?.summary?.processStatus === 'LOADING'
|
||||||
&& Number(blockedDetail?.summary?.version) === loadingVersion,
|
&& Number(blockedDetail?.summary?.version) === beforeHardGateVersion,
|
||||||
`status=${blockedDetail?.summary?.processStatus || 'missing'}, version=${blockedDetail?.summary?.version ?? 'missing'}`, OPTIONS)
|
`status=${blockedDetail?.summary?.processStatus || 'missing'}, version=${blockedDetail?.summary?.version ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
context.createdResources.phase3Loading = {
|
context.createdResources.phase3Loading = {
|
||||||
@@ -805,7 +811,7 @@ export async function run(context) {
|
|||||||
postLoadedFileId,
|
postLoadedFileId,
|
||||||
loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId,
|
loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId,
|
||||||
pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion,
|
pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion,
|
||||||
atomicBatchVerified: directPdaCompletion && atomicBatchReplayVerified,
|
atomicBatchVerified: awaitingReview && directPdaCompletion && atomicBatchReplayVerified,
|
||||||
inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified,
|
inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified,
|
||||||
inventoryMutationVerified, inventoryMutationCleanup,
|
inventoryMutationVerified, inventoryMutationCleanup,
|
||||||
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'COMPLETED', finalStatus: 'COMPLETED',
|
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'COMPLETED', finalStatus: 'COMPLETED',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import test from 'node:test'
|
|||||||
import { selectCustomsFlow } from './phase3-customs.mjs'
|
import { selectCustomsFlow } from './phase3-customs.mjs'
|
||||||
import { buildFinalizeResource } from './phase3-loading-finalize.mjs'
|
import { buildFinalizeResource } from './phase3-loading-finalize.mjs'
|
||||||
import { selectContainerLoadingFixture } from './phase3-container-loading.mjs'
|
import { selectContainerLoadingFixture } from './phase3-container-loading.mjs'
|
||||||
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
|
import { AcceptanceRun, normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||||
|
|
||||||
function source(name) {
|
function source(name) {
|
||||||
return readFileSync(new URL(name, import.meta.url), 'utf8')
|
return readFileSync(new URL(name, import.meta.url), 'utf8')
|
||||||
@@ -115,6 +115,11 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
|
|||||||
'const canonicalBatchReplay = await request',
|
'const canonicalBatchReplay = await request',
|
||||||
])
|
])
|
||||||
assert.match(loading, /inventorySnapshotToken: refreshedInventory\.snapshotToken/)
|
assert.match(loading, /inventorySnapshotToken: refreshedInventory\.snapshotToken/)
|
||||||
|
assert.match(loading, /warehouseId, loadingPlanId, containerBookingId,/)
|
||||||
|
assert.match(loading, /inventory\?\.currentContainerNo === containerNo/)
|
||||||
|
const batchPayload = loading.slice(loading.indexOf('const batchPayload ='), loading.indexOf('const staleBatch ='))
|
||||||
|
assert.doesNotMatch(batchPayload, /containerNo[,\s]/)
|
||||||
|
assert.doesNotMatch(loading, /\/loading\/picking\/(?:start|complete)/)
|
||||||
assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified/)
|
assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified/)
|
||||||
assert.match(loading, /inventoryMutationVerified, inventoryMutationCleanup/)
|
assert.match(loading, /inventoryMutationVerified, inventoryMutationCleanup/)
|
||||||
assert.match(loading, /当前唯一平台代理库存变更入口只允许 A0 临时占用换绑/)
|
assert.match(loading, /当前唯一平台代理库存变更入口只允许 A0 临时占用换绑/)
|
||||||
@@ -229,9 +234,10 @@ test('phase3 loading proves stale inventory zero-write for all items and complet
|
|||||||
'旧 token 冲突对任务、明细、已拣数量和操作历史零写入',
|
'旧 token 冲突对任务、明细、已拣数量和操作历史零写入',
|
||||||
'库存变化后刷新 PDA 实时库存快照',
|
'库存变化后刷新 PDA 实时库存快照',
|
||||||
'原子提交柜次 PDA 整批拣货',
|
'原子提交柜次 PDA 整批拣货',
|
||||||
'PDA 原子批次同参同键重放返回同一终态',
|
'PDA 原子批次同参同键重放保持待人工确认',
|
||||||
|
'人工确认柜次 PDA 装柜完成',
|
||||||
'回查原子 Batch 完成审计',
|
'回查原子 Batch 完成审计',
|
||||||
'合法 Batch 完成后保留 CLAIM/BATCH 操作者审计且无伪造 REVIEW',
|
'人工完成后保留 CLAIM/BATCH/COMPLETE_LOADING 审计',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -285,6 +291,23 @@ test('default acceptance order verifies loading evidence before finalizing LOADE
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('container overview always follows phase3 loading in default and custom module selections', () => {
|
||||||
|
const reordered = normalizeModuleOrder(['phase3-container-overview', 'orders', 'phase3-loading', 'finance'])
|
||||||
|
assert.deepEqual(reordered, ['orders', 'phase3-loading', 'phase3-container-overview', 'finance'])
|
||||||
|
assert.deepEqual(parseArguments(['--modules', 'phase3-container-overview,phase3-loading']).modules,
|
||||||
|
['phase3-loading', 'phase3-container-overview'])
|
||||||
|
|
||||||
|
const entry = source('../admin-test-env-acceptance.mjs')
|
||||||
|
assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/)
|
||||||
|
assert.match(entry, /modules: normalizeModuleOrder\(/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('overview does not fall back to a container fixture when loading evidence was requested', () => {
|
||||||
|
const overview = source('./phase3-container-overview.mjs')
|
||||||
|
assert.match(overview, /loadingRequested[\s\S]*\? context\.createdResources\?\.phase3Loading/)
|
||||||
|
assert.match(overview, /source=\$\{loadingRequested \? 'phase3Loading-required'/)
|
||||||
|
})
|
||||||
|
|
||||||
test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', async () => {
|
test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', async () => {
|
||||||
const run = new AcceptanceRun({ gateway: 'http://127.0.0.1', modules: [], username: '', password: '', output: '' })
|
const run = new AcceptanceRun({ gateway: 'http://127.0.0.1', modules: [], username: '', password: '', output: '' })
|
||||||
const entry = source('../admin-test-env-acceptance.mjs')
|
const entry = source('../admin-test-env-acceptance.mjs')
|
||||||
@@ -298,9 +321,10 @@ test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', asy
|
|||||||
assert.equal(run.marker.includes('-LOAD'), false)
|
assert.equal(run.marker.includes('-LOAD'), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('container fixture keeps the run marker and does not publish incomplete WAIT_PRICING resources', () => {
|
test('container fixture isolates order idempotency while publishing the run marker', () => {
|
||||||
const entry = source('../admin-test-env-acceptance.mjs')
|
const entry = source('../admin-test-env-acceptance.mjs')
|
||||||
assert.match(entry, /const fixtureMarker = originalMarker/)
|
assert.match(entry, /const fixtureMarker = `\$\{originalMarker\}-CAB`/)
|
||||||
|
assert.match(entry, /marker: originalMarker,\s*\n\s*sourceMarker: fixtureMarker/)
|
||||||
assert.match(entry, /fixture\.finalStatus !== 'APPROVED'/)
|
assert.match(entry, /fixture\.finalStatus !== 'APPROVED'/)
|
||||||
assert.match(entry, /禁止回退使用 WAIT_PRICING 订单/)
|
assert.match(entry, /禁止回退使用 WAIT_PRICING 订单/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const pipeline = (await readFile(new URL('../Jenkinsfile', import.meta.url), 'utf8'))
|
||||||
|
.replaceAll('\r\n', '\n')
|
||||||
|
|
||||||
|
test('AUTO detects database migrations without expanding them to all services', () => {
|
||||||
|
assert.match(pipeline, /def databaseMigrationPaths = \['deploy\/sql\/'\]/)
|
||||||
|
assert.match(pipeline, /!databaseMigration && commonPaths\.any/)
|
||||||
|
assert.match(pipeline, /env\.DATABASE_CHANGE_DETECTED = databaseChanged \? 'true' : 'false'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('database-only changes are reported as a separate migration action', () => {
|
||||||
|
assert.match(pipeline, /仅数据库迁移变更 \| 需单独审核执行/)
|
||||||
|
assert.match(pipeline, /统一 SQL 须独立审核并执行/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user