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:
2026-08-14 09:24:12 +08:00
53 changed files with 1272 additions and 357 deletions
Vendored
+17 -3
View File
@@ -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
View File
@@ -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);
}
}
@@ -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
View File
@@ -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,
@@ -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));
} }
} }
@@ -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) {}
} }
@@ -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}
@@ -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");
} }
@@ -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;
}
}
@@ -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) {
@@ -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() {
@@ -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);
@@ -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);
+28 -12
View File
@@ -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
} }
+28
View File
@@ -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)
})
+98 -9
View File
@@ -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 须独立审核并执行/)
})