Merge branch 'dev' into dev-lyhfive

# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
#	scripts/admin-test-env-modules/phase3-loading.mjs
This commit is contained in:
2026-08-27 13:40:26 +08:00
52 changed files with 2520 additions and 334 deletions
+1 -1
View File
@@ -84,7 +84,7 @@
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
## 全量 HTTP 验收脚本路由
Vendored
+106 -17
View File
@@ -21,16 +21,77 @@ def serviceCheckboxes = [
'audit-service': 'SERVICE_AUDIT'
]
def commonPaths = [
'pom.xml', 'platform-bom/', 'common-core/', 'common-web/', 'common-security/',
'common-openapi/', 'common-audit/', 'common-event-contract/', 'Jenkinsfile',
'Jenkinsfile.service', 'deploy/'
def fullRebuildPaths = [
'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/',
'deploy/docker-compose.yml', 'deploy/service-map.yml'
]
def commonPathServices = [
'common-web/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service'
],
'common-security/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
'lastleg-service', 'warehouse-overseas-service', 'audit-service'
],
'common-openapi/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service'
],
'common-audit/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
'warehouse-overseas-service', 'audit-service'
],
'common-event-contract/': [
'firstleg', 'iam', 'lastleg-service', 'event-bridge-service'
],
'deploy/docker-compose.overseas.yml': [
'gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service'
],
'deploy/docker-compose.audit.yml': ['audit-service'],
'deploy/docker-compose.rocketmq.yml': ['event-bridge-service']
]
def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:]
def parseAutoBuildState(String description, List<String> allowedServices) {
String marker = ' | RESULTS'
if (!description?.startsWith('AUTO ') || !description.contains(marker)) return null
int markerIndex = description.indexOf(marker)
String commit = description.substring('AUTO '.length(), markerIndex).trim()
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
String summary = description.substring(markerIndex + marker.length()).trim()
List<String> failedServices = []
if (summary) {
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
int separator = item.indexOf('=')
if (separator > 0) {
String service = item.substring(0, separator).trim()
String result = item.substring(separator + 1).trim()
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service
}
}
}
return [commit: commit, failedServices: failedServices.unique()]
}
def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
def candidate = previousBuild
int inspected = 0
while (candidate != null && inspected < 30) {
def state = parseAutoBuildState(candidate.description, allowedServices)
if (state != null && ['SUCCESS', 'UNSTABLE'].contains(candidate.result)) return state
candidate = candidate.previousBuild
inspected++
}
return null
}
def environmentChoicesForJob(String jobBaseName) {
return jobBaseName == 'backend-java-dev'
? ['development', 'test', 'staging', 'production']
@@ -112,6 +173,8 @@ pipeline {
error('生产环境禁止跳过测试')
}
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()
List selected = []
if (params.BUILD_MODE == 'ALL') {
selected = serviceModules
@@ -123,9 +186,17 @@ pipeline {
error('SELECT 模式至少需要勾选一个服务')
}
} else {
String base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
def autoState = previousAutoBuildState(currentBuild.previousBuild, serviceModules)
String base = autoState?.commit?.trim()
List retryServices = autoState?.failedServices ?: []
if (!base) {
// 首次启用新算法时兼容旧构建记录;后续只读取标记为 AUTO 的完成记录,
// SELECT/ALL 不会推进 AUTO 基线。
base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
}
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
selected = serviceModules
echo 'AUTO 未找到可用基线,安全回退为全量构建'
} else {
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
.trim().split('\\n').collect { it.trim() }.findAll { it }
@@ -133,13 +204,27 @@ pipeline {
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
}
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
boolean commonChanged = changed.any { path ->
boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
!databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
List serviceRelevantChanges = changed.findAll { path ->
!databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
}
selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
changed.any { it == module || it.startsWith("${module}/") }
boolean fullRebuild = serviceRelevantChanges.any { path ->
fullRebuildPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
}
if (fullRebuild) {
selected = serviceModules
} else {
selected = serviceModules.findAll { module ->
serviceRelevantChanges.any { it == module || it.startsWith("${module}/") }
}
commonPathServices.each { prefix, impactedServices ->
if (serviceRelevantChanges.any { path -> path == prefix || path.startsWith(prefix) }) {
selected.addAll(impactedServices)
}
}
}
selected.addAll(retryServices)
echo "AUTO 基线: ${base};变更选中: ${selected.unique().join(',') ?: '无'};" +
"补跑失败服务: ${retryServices.join(',') ?: '无'}"
}
}
@@ -165,14 +250,16 @@ pipeline {
env.SELECTED_SERVICES = selected.join(',')
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
if (!selected) {
currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true'
? '仅数据库迁移变更 | 需单独审核执行'
: '没有需要构建的业务服务'
currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | RESULTS"
: (env.DATABASE_CHANGE_DETECTED == 'true'
? '仅数据库迁移变更 | 需单独审核执行'
: '没有需要构建的业务服务')
} else {
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()
String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | PENDING ${env.SELECTED_SERVICES}${databaseSuffix}"
: "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
}
if (env.DATABASE_CHANGE_DETECTED == 'true') {
@@ -273,7 +360,9 @@ pipeline {
.collect { service, result -> "${service}=${result}" }
String summary = downstreamResults.collect { service, result -> "${service}=${result}" }.join(', ')
echo "单服务执行汇总: ${summary}"
currentBuild.description = "${env.IMAGE_TAG} | ${summary}"
currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
: "${env.IMAGE_TAG} | ${summary}"
if (failed) {
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
}
@@ -117,6 +117,7 @@ public class AuthService {
.subject(account.getId().toString())
.claim("user_id", account.getId())
.claim("username", account.getUsername())
.claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName())
.claim("tenant_id", account.getDefaultTenantId())
.claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId())
@@ -187,7 +188,7 @@ public class AuthService {
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer())
.audience(List.of(properties.audience())).id(jwtId)
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
.claim("user_id", account.getId()).claim("username", account.getUsername())
.claim("user_id", account.getId()).claim("username", account.getUsername()).claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName())
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
@@ -7,21 +7,36 @@ import java.util.Set;
* 统一认证用户主体,携带用户、租户、成员身份、应用端、客户端类型及权限信息。
*/
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType,
String username, String displayName, String appCode, String clientType,
Set<String> permissions, boolean impersonated, Long actorUserId,
Long actorMembershipId, boolean passwordChangeRequired,
Long overseasWarehouseId) implements Principal {
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions) {
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
permissions, false, null, null, false, null);
}
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions,
boolean impersonated, Long actorUserId, Long actorMembershipId,
boolean passwordChangeRequired) {
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
}
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String displayName, String appCode, String clientType,
Set<String> permissions, boolean impersonated, Long actorUserId,
Long actorMembershipId, boolean passwordChangeRequired) {
this(userId, tenantId, operatorTenantId, membershipId, username, displayName, appCode, clientType,
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
}
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions,
boolean impersonated, Long actorUserId, Long actorMembershipId,
boolean passwordChangeRequired, Long overseasWarehouseId) {
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired,
overseasWarehouseId);
}
public AuthenticatedUser {
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
@@ -37,6 +37,7 @@ public class JwtClaimsMapper {
longClaim(jwt, "operator_tenant_id"),
longClaim(jwt, "membership_id"),
username,
firstNonBlankNullable(jwt.getClaimAsString("displayName"), jwt.getClaimAsString("display_name")),
appCode,
jwt.getClaimAsString("client_type"),
permissions,
@@ -87,4 +88,9 @@ public class JwtClaimsMapper {
for (String value : values) if (value != null && !value.isBlank()) return value;
return "unknown";
}
private String firstNonBlankNullable(String... values) {
for (String value : values) if (value != null && !value.isBlank()) return value.trim();
return null;
}
}
@@ -890,6 +890,7 @@ CREATE TABLE IF NOT EXISTS wh_partner (
data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围',
partner_code VARCHAR(50) NOT NULL COMMENT '平台内唯一往来单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '往来单位名称',
partner_type VARCHAR(20) NOT NULL COMMENT '往来单位类型:CARRIER、SHIPPER或CONSIGNEE',
carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填',
contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(32) NOT NULL COMMENT '联系电话',
contact_email VARCHAR(128) NULL COMMENT '联系邮箱', country_code CHAR(2) NOT NULL COMMENT '国家二字码',
province VARCHAR(64) NULL COMMENT '省或州', city VARCHAR(64) NOT NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县',
@@ -901,7 +902,8 @@ CREATE TABLE IF NOT EXISTS wh_partner (
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted),
KEY idx_wh_partner_tenant_status(tenant_id,status,deleted),
CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))
CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')),
CONSTRAINT chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))
) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据';
CREATE TABLE IF NOT EXISTS wh_partner_type (
partner_id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0',
@@ -1872,7 +1874,8 @@ CALL add_column_if_missing('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL
CALL add_column_if_missing('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id');
CALL add_column_if_missing('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code');
CALL add_column_if_missing('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name');
CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type');
CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL COMMENT ''船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填'' AFTER partner_type');
CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER carrier_code');
CALL add_column_if_missing('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name');
CALL add_column_if_missing('wh_partner', 'contact_email', 'VARCHAR(128) NULL');
CALL add_column_if_missing('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email');
@@ -1888,6 +1891,8 @@ CALL add_column_if_missing('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL');
CALL add_column_if_missing('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_partner', 'version', 'INT NOT NULL DEFAULT 0');
UPDATE wh_partner SET carrier_code = 'OTHER'
WHERE id = 191000000000000001 AND partner_type = 'CARRIER' AND carrier_code IS NULL;
-- 该表在旧库中不存在时先由 warehouse-express-company.sql 建表;此段补齐早期部署缺列。
CALL add_column_if_missing('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id');
@@ -4950,11 +4955,14 @@ BEGIN
ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL
COMMENT '已关联真实订舱客户名称快照' AFTER customer_id;
END IF;
UPDATE fl_container_booking container_booking
JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE()
AND table_name='fl_ocean_booking') THEN
UPDATE fl_container_booking container_booking
JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no);
@@ -14748,6 +14756,7 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_opera
CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'target_business_id', '目标子入仓单ID');
CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'tenant_id', '仓储公共资源域,固定为0');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'address_line', '详细地址');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'carrier_code', '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'city', '城市');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'contact_email', '联系邮箱');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'contact_name', '联系人姓名');
@@ -13,8 +13,8 @@ final class FinanceOperatorSnapshot {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof AuthenticatedUser user
&& user.userId() != null && user.userId() == operatorId
&& StringUtils.hasText(user.username())) {
return user.username().trim();
&& (StringUtils.hasText(user.displayName()) || StringUtils.hasText(user.username()))) {
return StringUtils.hasText(user.displayName()) ? user.displayName().trim() : user.username().trim();
}
return null;
}
@@ -41,23 +41,26 @@ public class ReceivableStatisticsService {
LocalDate from = month.atDay(1);
LocalDate to = month.plusMonths(1).atDay(1);
StringBuilder sql = new StringBuilder("""
SELECT tenant_id, customer_name_snapshot,
SUM(total_amount) total_receivable,
SUM(paid_amount) total_received,
SUM(outstanding_amount) total_outstanding
FROM fin_settlement
WHERE direction='RECEIVABLE'
AND status<>'VOIDED'
AND currency_code='CNY'
AND generated_date>=?
AND generated_date<?
SELECT s.tenant_id,
COALESCE(p.customer_name_snapshot, MAX(s.customer_name_snapshot)) customer_name_snapshot,
SUM(s.total_amount) total_receivable,
SUM(s.paid_amount) total_received,
SUM(s.outstanding_amount) total_outstanding
FROM fin_settlement s
LEFT JOIN fin_customer_settlement_profile p
ON p.tenant_id=s.tenant_id AND p.status='ACTIVE'
WHERE s.direction='RECEIVABLE'
AND s.status<>'VOIDED'
AND s.currency_code='CNY'
AND s.generated_date>=?
AND s.generated_date<?
""");
List<Object> args = new ArrayList<>(List.of(from, to));
if (customerId != null) {
sql.append(" AND tenant_id=?");
sql.append(" AND s.tenant_id=?");
args.add(customerId);
}
sql.append(" GROUP BY tenant_id,customer_name_snapshot ORDER BY customer_name_snapshot,tenant_id");
sql.append(" GROUP BY s.tenant_id,p.customer_name_snapshot ORDER BY customer_name_snapshot,s.tenant_id");
return jdbc.query(sql.toString(), (rs, rowNum) -> checked(new ReceivableStatisticsRow(
rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), CURRENCY,
rs.getBigDecimal("total_receivable"), rs.getBigDecimal("total_received"),
@@ -40,8 +40,8 @@ class ReceivableStatisticsServiceTest {
assertThat(result.records()).containsExactly(rows.get(0));
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc).query(sql.capture(), any(RowMapper.class), any(Object[].class));
assertThat(sql.getValue()).contains("direction='RECEIVABLE'", "status<>'VOIDED'", "currency_code='CNY'",
"generated_date>=?", "generated_date<?", "GROUP BY tenant_id,customer_name_snapshot");
assertThat(sql.getValue()).contains("s.direction='RECEIVABLE'", "s.status<>'VOIDED'", "s.currency_code='CNY'",
"s.generated_date>=?", "s.generated_date<?", "fin_customer_settlement_profile", "GROUP BY s.tenant_id,p.customer_name_snapshot");
}
@Test
@@ -28,6 +28,16 @@ class FinanceOperatorSnapshotTest {
assertThat(FinanceOperatorSnapshot.currentName(72L)).isNull();
}
@Test
void prefersAuthenticatedDisplayNameOverLoginUsername() {
AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null,
"12555214478", "廖万昌", "MERCHANT", "WEB", Set.of(), false, null, null, false);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user, null));
assertThat(FinanceOperatorSnapshot.currentName(71L)).isEqualTo("廖万昌");
}
@Test
void labelsSystemGeneratedEntriesWithoutInventingAnOperator() {
assertThat(FinanceOperatorSnapshot.currentName(0L)).isEqualTo("系统");
+1 -1
View File
@@ -24,7 +24,7 @@
| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,旧 planNo/allocationId Controller 已移除 |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
@@ -47,6 +47,14 @@ public class ReceivingContainerController {
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
}
@GetMapping("/warehouse-profile")
@Operation(summary = "Get the authenticated warehouse picker's current Chinese warehouse master name")
public ApiResponse<ReceivingContainerResponses.WarehouseProfile> warehouseProfile(
@AuthenticationPrincipal AuthenticatedUser user) {
PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.warehouseProfile(identity.overseasWarehouseId()));
}
@GetMapping("/{containerNo}")
@Operation(summary = "Get one receiving container without exposing plan or allocation IDs")
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
@@ -104,14 +112,6 @@ public class ReceivingContainerController {
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
}
@PostMapping("/current/skus/resolve")
public ApiResponse<ReceivingContainerResponses.SkuResolved> resolveSku(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolveSku(identity.operatorKey(), request));
}
@PostMapping("/current/pallets/resolve")
public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet(
@AuthenticationPrincipal AuthenticatedUser user,
@@ -18,10 +18,8 @@ public final class ReceivingContainerRequests {
public record SignedPhoto(@NotNull ReceivingPhotoRole role,
@NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { }
public record CartonResolve(@NotBlank String cartonMark) { }
public record SkuResolve(@NotBlank @Size(max = 128) String skuCode) { }
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
public record BulkReceive(@NotBlank String inboundCode, @NotBlank @Size(max = 128) String skuCode,
@NotBlank String palletCode,
public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode,
@Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { }
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
@NotBlank @Size(max = 255) String idempotencyKey) { }
@@ -10,6 +10,8 @@ public final class ReceivingContainerResponses {
List<String> destinationCodes, int plannedPalletCount,
int totalPackages, int receivedPackages, boolean currentAccount) { }
public record WarehouseProfile(String warehouseId, String warehouseCode, String warehouseName) { }
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
@@ -26,20 +28,13 @@ public final class ReceivingContainerResponses {
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
public record CartonResolved(String cartonMark, String skuCode, String inboundCode,
public record CartonResolved(String cartonMark, String inboundCode,
String destinationType, String overseasWarehouseId,
String overseasWarehouseName, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
public record SkuResolved(String skuCode, String productName, String inboundCode,
String destinationType, String overseasWarehouseId,
String overseasWarehouseName, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
public record PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
String destinationType, boolean compatible) { }
@@ -87,6 +87,12 @@ public class ReceivingContainerService {
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
}
public ReceivingContainerResponses.WarehouseProfile warehouseProfile(long overseasWarehouseId) {
WarehouseResourceGateway.WarehouseReference value = warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
return new ReceivingContainerResponses.WarehouseProfile(
Long.toString(value.warehouseId()), value.warehouseCode(), value.warehouseName());
}
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
@@ -220,42 +226,28 @@ public class ReceivingContainerService {
ReceivingContainerRequests.CartonResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<InboundRef> currentRefs = refs(customerId, session.getContainerNo());
if (currentRefs.stream().noneMatch(value ->
"OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count");
}
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
if (carton == null) throw notFound("Carton mark was not found");
InboundRef ref = currentRefs.stream()
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA inbound must use bulk count instead of carton scan");
String inboundCode = cartonInboundCode(request.cartonMark());
List<InboundRef> matches = refs(customerId, session.getContainerNo()).stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
CartonRef carton;
if (matches.size() > 1) {
throw conflict("Carton mark inbound code is ambiguous in the current container");
} else if (matches.size() == 1 && "FBA".equals(destinationType(matches.get(0).line(), matches.get(0).order()))) {
carton = new CartonRef(matches.get(0));
} else if (matches.size() == 1) {
carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark());
} else {
carton = requireUnmatchedFbaCartonPrefix(customerId, inboundCode);
}
InboundRef ref = carton.ref();
ReceivingContainerResponses.Inbound inbound = inbound(ref);
return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(),
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(),
inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(),
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
}
public ReceivingContainerResponses.SkuResolved resolveSku(String operatorKey,
ReceivingContainerRequests.SkuResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<SkuRef> matches = refs(customerId, session.getContainerNo()).stream()
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.map(ref -> skuRef(ref, request.skuCode()))
.filter(Objects::nonNull)
.toList();
if (matches.isEmpty()) throw notFound("SKU was not found in the current FBA container");
if (matches.size() != 1) throw conflict("SKU belongs to multiple inbound codes in the current container");
return skuResponse(matches.get(0));
}
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
ReceivingContainerRequests.PalletResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
@@ -278,22 +270,27 @@ public class ReceivingContainerService {
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
}
SkuRef sku = requireSku(ref, request.skuCode());
validateSkuPending(customerId, sku, request.packageCount());
workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount());
List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(customerId, session.getContainerNo(), ref);
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount());
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(),
allocation.ref().line(), allocation.packages()));
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode());
validatePallet(ref, resource);
InboundPlanPallet pallet = choosePallet(ref, resource);
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
try {
int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet,
request.packageCount(), operatorKey, operatorName);
recordSkuReceipt(session, sku, pallet, accepted);
int accepted = 0;
for (FbaSkuAllocation allocation : skuAllocations) {
accepted += workflow.receiveBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), pallet,
allocation.packages(), operatorKey, operatorName);
recordSkuReceipt(session, allocation, pallet);
}
markReceiving(session);
ReceivingContainerResponses.Inbound inbound = inbound(ref);
int received = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum();
int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum();
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted,
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus());
session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted,
received, Math.max(0, total - received), session.getStatus());
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
@@ -314,12 +311,8 @@ public class ReceivingContainerService {
"OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count");
}
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
if (carton == null) throw notFound("Carton mark was not found");
InboundRef ref = currentRefs.stream()
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark());
InboundRef ref = carton.ref();
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA destination must use bulk count");
}
@@ -420,7 +413,8 @@ public class ReceivingContainerService {
List<InboundRef> result = new ArrayList<>();
for (InboundPlan plan : plansForContainer(customerId, containerNo)) {
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) {
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId())
.orderByAsc(InboundPlanLine::getId))) {
LastlegOrder order = orders.selectById(line.getOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing");
result.add(new InboundRef(plan, line, order));
@@ -433,8 +427,53 @@ public class ReceivingContainerService {
List<InboundRef> matches = refs(customerId, containerNo).stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container");
if (matches.size() != 1) throw conflict("Inbound code is duplicated inside the current container");
return matches.get(0);
Set<Long> planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet());
if (planIds.size() != 1) throw conflict("Inbound code is duplicated inside the current container");
return matches.stream().min(Comparator.comparing(value -> value.line().getId())).orElseThrow();
}
private CartonRef requireCartonRef(String customerId, String containerNo, String cartonMark) {
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark));
if (carton == null) throw notFound("Carton mark was not found");
List<InboundRef> matches = refs(customerId, containerNo).stream()
.filter(value -> carton.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId()))
.filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId()))
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).toList();
if (matches.size() != 1) throw conflict("Carton mark belongs to another container");
return new CartonRef(matches.get(0));
}
private String cartonInboundCode(String cartonMark) {
int delimiter = cartonMark == null ? -1 : cartonMark.indexOf('#');
if (delimiter < 1) throw conflict("Carton mark must contain a non-empty inbound code prefix");
String inboundCode = cartonMark.substring(0, delimiter).trim();
if (inboundCode.isEmpty()) throw conflict("Carton mark must contain a non-empty inbound code prefix");
return inboundCode;
}
private CartonRef requireUnmatchedFbaCartonPrefix(String customerId, String inboundCode) {
boolean exists = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId)
.eq(InboundPlanLine::getInboundCode, inboundCode)).stream().findAny().isPresent();
if (exists) throw conflict("Carton mark belongs to another container");
throw notFound("Carton mark was not found");
}
private List<InboundRef> requireFbaPlanRefs(String customerId, String containerNo, InboundRef selected) {
List<InboundRef> values = refs(customerId, containerNo).stream()
.filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId()))
.sorted(Comparator.comparing(value -> value.line().getId())).toList();
if (values.isEmpty() || values.stream().anyMatch(value ->
!"FBA".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA bulk receiving requires every line in the selected inbound plan to be FBA");
}
Set<String> destinations = values.stream().map(value -> trim(value.line().getFbaWarehouseCode()))
.collect(Collectors.toSet());
if (destinations.size() != 1 || destinations.contains(null)) {
throw conflict("FBA bulk receiving requires one destination warehouse per inbound plan");
}
return values;
}
private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) {
@@ -547,44 +586,54 @@ public class ReceivingContainerService {
throw conflict("Unload photo file ID must be a positive decimal string");
}
private SkuRef requireSku(InboundRef ref, String skuCode) {
SkuRef result = skuRef(ref, skuCode);
if (result == null) throw notFound("SKU is not part of the selected inbound code");
private List<FbaSkuAllocation> allocateFbaSkuReceipts(String customerId, List<InboundRef> refs, int packageCount) {
int remaining = packageCount;
List<FbaSkuAllocation> result = new ArrayList<>();
for (InboundRef ref : refs) {
int linePending = value(ref.line().getRequiredPackageCount()) - receivedLinePackages(ref);
List<SkuExpectation> expectations = skuExpectations(ref);
int skuPending = expectations.stream().mapToInt(expectation -> expectation.expectedPackages()
- receivedSkuPackages(customerId, ref.line().getId(), expectation.skuCode())).sum();
if (linePending < 0 || skuPending != linePending) {
throw conflict("FBA SKU receipt progress must exactly match the inbound line progress");
}
for (SkuExpectation expectation : expectations) {
if (remaining == 0) break;
int skuPendingPackages = expectation.expectedPackages()
- receivedSkuPackages(customerId, ref.line().getId(), expectation.skuCode());
int accepted = Math.min(remaining, skuPendingPackages);
if (accepted > 0) {
result.add(new FbaSkuAllocation(ref, expectation.skuCode(), expectation.productName(), accepted));
remaining -= accepted;
}
}
if (remaining == 0) break;
}
if (remaining != 0) throw conflict("Received package count exceeds the FBA inbound pending total");
return result;
}
private SkuRef skuRef(InboundRef ref, String skuCode) {
String requested = trim(skuCode);
if (requested == null) return null;
private List<SkuExpectation> skuExpectations(InboundRef ref) {
List<LastlegOrderDetail> details = orderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, ref.order().getCustomerId())
.eq(LastlegOrderDetail::getOrderId, ref.order().getId()).orderByAsc(LastlegOrderDetail::getId));
List<LastlegOrderDetail> matched = details.stream()
.filter(detail -> requested.equalsIgnoreCase(trim(detail.getSkuCode()))).toList();
int expected = matched.stream().mapToInt(detail -> value(detail.getPackageCount())).sum();
if (expected <= 0) return null;
String canonicalSku = matched.stream().map(LastlegOrderDetail::getSkuCode).map(ReceivingContainerService::trim)
.filter(Objects::nonNull).findFirst().orElse(requested);
String productName = matched.stream().map(detail -> first(detail.getProductNameZh(), detail.getProductNameEn()))
.filter(Objects::nonNull).findFirst().orElse(null);
return new SkuRef(ref, canonicalSku, productName, expected);
}
private ReceivingContainerResponses.SkuResolved skuResponse(SkuRef sku) {
ReceivingContainerResponses.Inbound inbound = inbound(sku.ref());
int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode());
return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(),
inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(),
inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(),
inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(),
sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received));
}
private void validateSkuPending(String customerId, SkuRef sku, int packageCount) {
int received = receivedSkuPackages(customerId, sku.ref().line().getId(), sku.skuCode());
if (packageCount <= 0 || packageCount > sku.expectedPackages() - received) {
throw conflict("Received package count exceeds the SKU pending total");
Map<String, SkuExpectation> grouped = new LinkedHashMap<>();
for (LastlegOrderDetail detail : details) {
String skuCode = trim(detail.getSkuCode());
int expected = value(detail.getPackageCount());
if (skuCode == null || expected <= 0) continue;
String key = skuCode.toUpperCase(java.util.Locale.ROOT);
SkuExpectation existing = grouped.get(key);
grouped.put(key, existing == null ? new SkuExpectation(skuCode,
first(detail.getProductNameZh(), detail.getProductNameEn()), expected)
: new SkuExpectation(existing.skuCode(), existing.productName(), existing.expectedPackages() + expected));
}
List<SkuExpectation> result = List.copyOf(grouped.values());
int expected = result.stream().mapToInt(SkuExpectation::expectedPackages).sum();
if (result.isEmpty() || expected != value(ref.line().getRequiredPackageCount())) {
throw conflict("Frozen FBA SKU package snapshots must exactly match the inbound line total");
}
return result;
}
private int receivedSkuPackages(String customerId, Long lineId, String skuCode) {
@@ -592,7 +641,15 @@ public class ReceivingContainerService {
.mapToInt(value -> value(value.getReceivedPackages())).sum();
}
private void recordSkuReceipt(ReceivingContainerSession session, SkuRef sku, InboundPlanPallet pallet, int packages) {
private int receivedLinePackages(InboundRef ref) {
return allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream()
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
}
private void recordSkuReceipt(ReceivingContainerSession session, FbaSkuAllocation sku, InboundPlanPallet pallet) {
List<ReceivingSkuReceipt> existing = skuReceipts.lockLineSku(session.getCustomerId(),
sku.ref().line().getId(), sku.skuCode());
ReceivingSkuReceipt receipt = existing.stream()
@@ -604,11 +661,11 @@ public class ReceivingContainerService {
receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId());
receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId());
receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode());
receipt.setProductName(sku.productName()); receipt.setReceivedPackages(packages);
receipt.setProductName(sku.productName()); receipt.setReceivedPackages(sku.packages());
receipt.setCreatedAt(now); receipt.setUpdatedAt(now); receipt.setVersion(0);
skuReceipts.insert(receipt);
} else {
receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + packages);
receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + sku.packages());
receipt.setUpdatedAt(now);
if (skuReceipts.updateById(receipt) != 1) throw conflict("SKU receiving progress changed concurrently");
}
@@ -617,21 +674,14 @@ public class ReceivingContainerService {
private void validateFbaSkuBalance(String customerId, String containerNo) {
for (InboundRef ref : refs(customerId, containerNo)) {
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue;
List<LastlegOrderDetail> details = orderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, ref.order().getId()));
Set<String> skuCodes = details.stream().map(LastlegOrderDetail::getSkuCode)
.map(ReceivingContainerService::trim).filter(Objects::nonNull)
.map(value -> value.toUpperCase(java.util.Locale.ROOT))
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
int expected = 0;
List<SkuExpectation> skuCodes = skuExpectations(ref);
int received = 0;
for (String skuCode : skuCodes) {
SkuRef sku = requireSku(ref, skuCode);
for (SkuExpectation sku : skuCodes) {
int skuReceived = receivedSkuPackages(customerId, ref.line().getId(), sku.skuCode());
if (skuReceived != sku.expectedPackages()) throw conflict("Every FBA SKU must be fully received before completing the container");
expected += sku.expectedPackages(); received += skuReceived;
received += skuReceived;
}
if (skuCodes.isEmpty() || expected != value(ref.line().getRequiredPackageCount()) || received != expected) {
if (received != value(ref.line().getRequiredPackageCount())) {
throw conflict("FBA SKU package snapshots must exactly match the inbound code total");
}
}
@@ -784,7 +834,9 @@ public class ReceivingContainerService {
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { }
private record SkuRef(InboundRef ref, String skuCode, String productName, int expectedPackages) { }
private record CartonRef(InboundRef ref) { }
private record SkuExpectation(String skuCode, String productName, int expectedPackages) { }
private record FbaSkuAllocation(InboundRef ref, String skuCode, String productName, int packages) { }
private record Totals(int inboundCount, List<String> destinationCodes, int plannedPalletCount,
int total, int received) { }
private record PrepareCommand(String containerNo, String idempotencyKey) { }
@@ -9,6 +9,12 @@ paths:
description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用;仅返回已单调消费 PICKUP_READY(80) 或更高实际船节点、状态为 RECEIVING_AVAILABLE 的柜,不接收 customerId。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } }
/api/pda/v1/last-leg/receiving-containers/warehouse-profile:
get:
summary: 查询当前PDA账号绑定的海外仓主档
description: 权限 lastleg:pda:operate;仓库ID只取Token,名称与编码由共享仓库主档实时解析,不接收warehouseId参数。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前绑定仓库中文名称与编码, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWarehouseProfileApiResponse' } } } }, '403': { description: 账号未绑定有效海外仓 } }
/api/pda/v1/last-leg/receiving-containers/{containerNo}:
get:
summary: 查询待签收柜号详情
@@ -66,17 +72,10 @@ paths:
/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve:
post:
summary: 解析当前柜箱唛
description: 权限 lastleg:pda:operate;仅 OVERSEAS 目的类型可逐箱收货,返回该箱所属入仓码完整信息。
description: 权限 lastleg:pda:operate;FBA 从完整 cartonMark 的第一个 # 前提取固定入仓码,并精确匹配当前锁定柜唯一 FBA line;不要求逐箱 manifest,不校验 SKU 或箱序号。OVERSEAS 必须命中真实 manifest。两者均不返回 SKU,也不登记收货。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } }
responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '409': { description: 跨柜、重复箱或目的类型不符 } }
/api/pda/v1/last-leg/receiving-containers/current/skus/resolve:
post:
summary: 解析当前柜 FBA SKU
description: 权限 lastleg:pda:operate;仅解析当前已签柜内唯一归属一个入仓码的 FBA SKU,返回冻结箱数与实收进度。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [skuCode], properties: { skuCode: { type: string, minLength: 1, maxLength: 128 } } } } } }
responses: { '200': { description: SKU与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSkuResolvedApiResponse' } } } }, '404': { description: A0404:SKU不在当前FBA柜内 }, '409': { description: A0409:SKU跨多个入仓码导致归属不唯一 } }
responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '404': { description: 入仓码未命中当前柜,或 OVERSEAS 箱码未命中真实 manifest }, '409': { description: 缺少 #、空入仓码前缀、跨柜或当前柜多重命中 } }
/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve:
post:
summary: 解析当前柜实物托盘码
@@ -87,7 +86,7 @@ paths:
/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk:
post:
summary: FBA 入仓码按箱数收货
description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;不接收 customerId、planNo、allocationId、warehouseId、locationId。
description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;服务端按当前入仓码所属计划的 line.id ASC 和冻结 SKU 明细顺序拆分箱数并记录真实 SKU 收货;不接收 SKU、customerId、planNo、allocationId、warehouseId、locationId。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } }
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 海外仓批量、超收或托盘不匹配 } }
@@ -706,10 +705,9 @@ components:
downloadPath: { type: string, description: 用于会话恢复时直接下载签收照片 }
ReceivingBulk:
type: object
required: [inboundCode, skuCode, palletCode, packageCount, idempotencyKey]
required: [inboundCode, palletCode, packageCount, idempotencyKey]
properties:
inboundCode: { type: string, minLength: 1 }
skuCode: { type: string, minLength: 1, maxLength: 128 }
palletCode: { type: string, minLength: 1 }
packageCount: { type: integer, minimum: 1 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
@@ -745,6 +743,13 @@ components:
totalPackages: { type: integer }
receivedPackages: { type: integer }
pendingPackages: { type: integer }
ReceivingWarehouseProfile:
type: object
required: [warehouseId, warehouseCode, warehouseName]
properties:
warehouseId: { type: string, pattern: '^\d+$' }
warehouseCode: { type: string }
warehouseName: { type: string, description: 国内Warehouse Service主档维护的当前中文名称 }
ReceivingCandidate:
type: object
required: [containerNo, status, planCount, inboundCodeCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, currentAccount]
@@ -792,15 +797,6 @@ components:
required: [cartonMark]
properties:
cartonMark: { type: string }
skuCode: { type: string, nullable: true }
ReceivingSkuResolved:
allOf:
- { $ref: '#/components/schemas/ReceivingInbound' }
- type: object
required: [skuCode]
properties:
skuCode: { type: string }
productName: { type: string, nullable: true }
ReceivingPalletResolved:
type: object
required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible]
@@ -831,6 +827,12 @@ components:
- type: object
required: [data]
properties: { data: { type: array, items: { $ref: '#/components/schemas/ReceivingCandidate' } } }
ReceivingWarehouseProfileApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
- type: object
required: [data]
properties: { data: { $ref: '#/components/schemas/ReceivingWarehouseProfile' } }
ReceivingSessionApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
@@ -849,12 +851,6 @@ components:
- type: object
required: [data]
properties: { data: { $ref: '#/components/schemas/ReceivingCartonResolved' } }
ReceivingSkuResolvedApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
- type: object
required: [data]
properties: { data: { $ref: '#/components/schemas/ReceivingSkuResolved' } }
ReceivingPalletResolvedApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
@@ -10,6 +10,7 @@ import java.util.Map;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InboundPlanPermissionContractTest {
private static final String LIST = "platform:overseas-warehouse:inbound-order:list";
@@ -37,7 +38,7 @@ class InboundPlanPermissionContractTest {
.orElseThrow();
assertEquals("hasAuthority('" + CREATE + "')", method.getAnnotation(PreAuthorize.class).value());
assertEquals("Required authority: " + CREATE, method.getAnnotation(Operation.class).description());
assertTrue(method.getAnnotation(Operation.class).description().contains("Required authority: " + CREATE));
}
@Test
@@ -124,8 +124,9 @@ class ReceivingContainerContractTest {
String yaml = resource("openapi/overseas-business-v1.yaml");
String prefix = "/api/pda/v1/last-leg/receiving-containers";
assertTrue(yaml.contains(prefix + "/candidates:"));
assertTrue(yaml.contains(prefix + "/warehouse-profile:"));
assertTrue(yaml.contains(prefix + "/current/receipts/bulk:"));
assertTrue(yaml.contains(prefix + "/current/skus/resolve:"));
assertFalse(yaml.contains(prefix + "/current/skus/resolve:"));
assertTrue(yaml.contains(prefix + "/current/receipts/carton:"));
assertTrue(yaml.contains(prefix + "/current/complete:"));
assertTrue(yaml.contains(prefix + "/current/unload-photos/files/{fileId}/download:"));
@@ -145,6 +146,10 @@ class ReceivingContainerContractTest {
assertTrue(yaml.contains("overseasWarehouseId: { type: string"));
assertTrue(yaml.contains("overseasWarehouseName: { type: string"));
assertFalse(receivingPaths.contains("photoFileIds"));
assertFalse(receivingPaths.contains("skuCode"));
assertFalse(yaml.contains("ReceivingSkuResolved"));
assertFalse(Arrays.stream(ReceivingContainerRequests.class.getDeclaredClasses())
.anyMatch(type -> "SkuResolve".equals(type.getSimpleName())));
}
private String resource(String name) throws IOException {
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
@@ -45,6 +46,17 @@ class ReceivingContainerControllerTest {
verify(service).candidates("42", 900L);
}
@Test
void warehouseProfileUsesOnlyTheTokenBoundWarehouse() {
when(service.warehouseProfile(900L)).thenReturn(
new ReceivingContainerResponses.WarehouseProfile("900", "US-LAX-01", "洛杉矶一号仓"));
var response = controller.warehouseProfile(picker());
assertThat(response.data().warehouseName()).isEqualTo("洛杉矶一号仓");
verify(service).warehouseProfile(900L);
}
@Test
void merchantIdentityCannotEnterPdaReceivingApiEvenWithForgedAuthority() {
AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB",
@@ -85,6 +97,19 @@ class ReceivingContainerControllerTest {
.hasMessageContaining("403");
}
@Test
void fbaCartonResolutionDelegatesWithoutControllerModeRejection() {
ReceivingContainerRequests.CartonResolve request =
new ReceivingContainerRequests.CartonResolve("NYEXX2608260001#4-1");
when(service.resolveCarton("42", request)).thenReturn(new ReceivingContainerResponses.CartonResolved(
"NYEXX2608260001#4-1", "NYEXX2608260001", "FBA", "900", "Shared LAX", null, null, null,
"ABE3", 1, 4, 0, 4));
controller.resolveCarton(picker(), request);
verify(service).resolveCarton("42", request);
}
private static AuthenticatedUser picker() {
return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
Set.of(PdaWarehouseIdentity.PICKER_ROLE, ReceivingContainerController.OPERATE_PERMISSION),
@@ -182,7 +182,7 @@ class ReceivingContainerServiceBehaviorTest {
void destinationModesAreHardRejectedBeforeWarehouseCalls() {
stubCurrent("OVERSEAS", "CONT-A");
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "bulk-key")))
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "bulk-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
@@ -209,7 +209,7 @@ class ReceivingContainerServiceBehaviorTest {
stubSku();
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "pallet-key")))
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "pallet-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@@ -223,7 +223,7 @@ class ReceivingContainerServiceBehaviorTest {
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 2, "over-key")))
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 2, "over-key")))
.isInstanceOf(ResponseStatusException.class);
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
@@ -291,7 +291,7 @@ class ReceivingContainerServiceBehaviorTest {
doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any());
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "compensate-key")))
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "compensate-key")))
.isSameAs(failure);
verify(workflow).compensateReservation("100", 55L, failure);
}
@@ -391,7 +391,7 @@ class ReceivingContainerServiceBehaviorTest {
}
@Test
void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() {
void bulkRejectsAggregateSkuOverReceiptBeforeWarehouseLookup() {
stubCurrent("FBA", "CONT-A");
stubSku();
com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt received =
@@ -400,11 +400,116 @@ class ReceivingContainerServiceBehaviorTest {
when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received));
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "sku-over")))
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "sku-over")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
}
@Test
void fbaCartonResolutionWithoutManifestForNyexx2608260001DoesNotWriteReceiptState() {
stubCurrent("FBA", "CONT-A");
InboundPlanLine fba = line("FBA");
fba.setInboundCode("NYEXX2608260001"); fba.setRequiredPackageCount(4);
when(lines.selectList(any())).thenReturn(List.of(fba));
var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("NYEXX2608260001#4-1"));
var replay = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("NYEXX2608260001#4-1"));
assertThat(resolved.cartonMark()).isEqualTo("NYEXX2608260001#4-1");
assertThat(resolved.inboundCode()).isEqualTo("NYEXX2608260001");
assertThat(resolved.destinationType()).isEqualTo("FBA");
assertThat(replay).isEqualTo(resolved);
verifyNoInteractions(workflow, operations);
}
@Test
void fbaCartonResolutionRejectsCrossContainerAndInvalidPrefixesAsConflicts() {
stubCurrent("FBA", "CONT-A");
InboundPlanLine current = line("FBA");
current.setInboundCode("NYEXX2608260001"); current.setRequiredPackageCount(4);
InboundPlanLine foreign = line("FBA");
foreign.setInboundCode("NYEXX2608269999"); foreign.setPlanId(99L);
when(lines.selectList(any())).thenReturn(List.of(current), List.of(foreign));
assertThatThrownBy(() -> service.resolveCarton("7",
new ReceivingContainerRequests.CartonResolve("NYEXX2608269999#4-1")))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(409));
assertThatThrownBy(() -> service.resolveCarton("7",
new ReceivingContainerRequests.CartonResolve("NYEXX2608260001")))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(409));
assertThatThrownBy(() -> service.resolveCarton("7",
new ReceivingContainerRequests.CartonResolve("#4-1")))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(409));
org.mockito.Mockito.reset(lines, cartons);
when(lines.selectList(any())).thenReturn(List.of(current), List.of());
assertThatThrownBy(() -> service.resolveCarton("7",
new ReceivingContainerRequests.CartonResolve("NYEXX26082600001#4-1")))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(404));
InboundPlanLine duplicate = line("FBA");
duplicate.setId(20L); duplicate.setInboundCode("NYEXX2608260001");
org.mockito.Mockito.reset(lines);
when(lines.selectList(any())).thenReturn(List.of(current, duplicate));
assertThatThrownBy(() -> service.resolveCarton("7",
new ReceivingContainerRequests.CartonResolve("NYEXX2608260001#any-tail")))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(409));
}
@Test
void overseasCartonResolutionWithoutManifestIsNotFound() {
stubCurrent("OVERSEAS", "CONT-A");
assertThatThrownBy(() -> service.resolveCarton("7",
new ReceivingContainerRequests.CartonResolve("IN-1#2-1")))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(404));
}
@Test
void fbaBulkDistributesAcrossLinesAndFrozenSkusInStableLineOrder() {
InboundPlan plan = plan("CONT-A");
InboundPlanLine first = line("FBA");
InboundPlanLine second = line("FBA");
second.setId(20L); second.setOrderId(2L); second.setRequiredPackageCount(2);
LastlegOrder firstOrder = order("FBA");
LastlegOrder secondOrder = order("FBA"); secondOrder.setId(2L);
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "SIGNED"));
when(plans.selectList(any())).thenReturn(List.of(plan));
when(lines.selectList(any())).thenReturn(List.of(second, first));
when(orders.selectById(1L)).thenReturn(firstOrder);
when(orders.selectById(2L)).thenReturn(secondOrder);
when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-A", 1), detail("SKU-B", 1)),
List.of(detail("SKU-C", 2)));
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
.thenAnswer(invocation -> invocation.getArgument(4));
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
var result = service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 3, "split-key"));
assertThat(result.acceptedPackages()).isEqualTo(3);
ArgumentCaptor<InboundPlanLine> capturedLines = ArgumentCaptor.forClass(InboundPlanLine.class);
ArgumentCaptor<Integer> capturedCounts = ArgumentCaptor.forClass(Integer.class);
verify(workflow, org.mockito.Mockito.times(3)).receiveBulkResolved(any(), any(), capturedLines.capture(), any(),
capturedCounts.capture(), any(), any());
assertThat(capturedLines.getAllValues()).containsExactly(first, first, second);
assertThat(capturedCounts.getAllValues()).containsExactly(1, 1, 1);
ArgumentCaptor<com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt> receipts =
ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt.class);
verify(skuReceipts, org.mockito.Mockito.times(3)).insert(receipts.capture());
assertThat(receipts.getAllValues()).extracting(value -> value.getSkuCode())
.containsExactly("SKU-A", "SKU-B", "SKU-C");
}
private void stubCurrent(String destinationType, String containerNo) {
InboundPlan plan = plan(containerNo);
InboundPlanLine line = line(destinationType);
+2 -1
View File
@@ -8,6 +8,7 @@
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 |
| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用固定客户账号创建指定数量订单 | 登录 `17512095155`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 |
| Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 |
| 人工分柜至 LOADED 分段验收 | `scripts/admin-test-env-acceptance.mjs` 的 `manual-*-fixture/verify` 可选模块;步骤和参数见 `scripts/admin-test-env-modules/README.md` | fixture 通过正式 HTTP 准备本轮数据,verify 只读核验;不进入默认全量 | `WAIT_SPLIT → 人工分柜 → 推送预装 → 订舱关联/计划确认 → PDA 领取与原子 batch → 五类证据/完成 → LOADED` |
执行前必须确认目标环境、账号和数据清理/停用策略,并获得当前对话的测试环境验收授权。`admin-test-env-modules/*contract.test.mjs` 只验证模块脚本结构,不能代替 HTTP 验收。
@@ -31,7 +32,7 @@ node scripts/create-pending-review-orders.mjs
| Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` |
| PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` |
| 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` |
| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 |
| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 |
| Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 用显式 `--require-jacoco --require-spotbugs` 检查报告完整性且当前允许失败 |
| 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 |
+66 -6
View File
@@ -6,12 +6,19 @@ import { pathToFileURL } from 'node:url'
import process from 'node:process'
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify',
'manual-preload-push-fixture', 'manual-preload-push-verify',
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
'manual-loading-complete-fixture', 'manual-loading-complete-verify']
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
const MODULE_DEPENDENCIES = new Map([
['suppliers', ['merchant-qualification-fixture']],
['supplemental', ['merchant-qualification-fixture']],
['orders', ['merchant-qualification-fixture']],
['inbound-measurement', ['orders']],
['manual-order-splitting-fixture', ['inbound-measurement']],
['phase3-container-loading', ['inbound-measurement']],
['phase3-loading', ['phase3-container-loading']],
['phase3-container-overview', ['phase3-loading']],
@@ -243,6 +250,12 @@ function usage() {
--channel-code <code> 固定物流渠道编码
--order-profile-mode <mode> 订单档案模式:fixed 或 randomized,默认fixed
--wait-split-count <n> 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1
--manual-split-fixture-report <path> 人工分柜准备报告路径,仅只读核验模块使用
--manual-split-plan-id <id> 人工操作产生的智能装柜计划 ID,仅只读核验模块使用
--manual-preload-source-report <path> 人工推送预装前置或核验来源报告路径
--manual-pda-source-report <path> 人工订舱关联、预装确认前置或核验来源报告路径
--manual-pda-batch-source-report <path> PDA 人工领取、扫码批量提交前置或核验来源报告路径
--manual-loading-complete-source-report <path> 五类证据与 PDA 最终完成前置或核验来源报告路径
--output <path> JSON 报告路径,默认 reports/
--help 显示帮助
@@ -267,9 +280,9 @@ export function parseArguments(argv) {
values.set(name, next); index += 1
}
}
const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean))
const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || ''
const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || ''
const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean))
if (merchantUsername && merchantPassword) {
const fixtureIndex = modules.indexOf('merchant-qualification-fixture')
if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1)
@@ -287,12 +300,21 @@ export function parseArguments(argv) {
channelName: values.get('channel-name') || '',
channelCode: values.get('channel-code') || '',
orderProfileMode: values.get('order-profile-mode') || 'fixed',
waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT || 1),
waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT
|| (modules.includes('manual-order-splitting-fixture') ? 6 : 1)),
manualSplitFixtureReport: values.get('manual-split-fixture-report') || '',
manualSplitPlanId: values.get('manual-split-plan-id') || '',
manualPreloadSourceReport: values.get('manual-preload-source-report') || '',
manualPdaSourceReport: values.get('manual-pda-source-report') || '',
manualPdaBatchSourceReport: values.get('manual-pda-batch-source-report') || '',
manualLoadingCompleteSourceReport: values.get('manual-loading-complete-source-report') || '',
output: values.get('output') || '',
}
}
export function validateExecutionOptions(options) {
const unavailable = options.modules.filter((module) => !AVAILABLE_MODULES.has(module))
if (unavailable.length > 0) throw new Error(`未注册的 HTTP 验收模块: ${unavailable.join(',')}`)
for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) {
if (typeof value !== 'string' || value.trim() === '') throw new Error(`缺少必填执行参数: ${name}`)
}
@@ -321,9 +343,39 @@ export function validateExecutionOptions(options) {
throw new Error('randomized订单档案必须同时固定起运仓、渠道名称和渠道编码')
}
if (options.waitSplitCount > 1) {
const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement'])
const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement',
'manual-order-splitting-fixture'])
if (options.modules.some((module) => !fixtureModules.has(module))) {
throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement')
throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement、manual-order-splitting-fixture')
}
}
if (options.modules.includes('manual-order-splitting-fixture') && options.waitSplitCount < 4) {
throw new Error('manual-order-splitting-fixture要求wait-split-count至少为4')
}
if (options.modules.includes('manual-order-splitting-verify')) {
if (!options.manualSplitFixtureReport) throw new Error('manual-order-splitting-verify缺少manual-split-fixture-report')
if (!/^[1-9][0-9]*$/.test(String(options.manualSplitPlanId))) {
throw new Error('manual-order-splitting-verify要求manual-split-plan-id为正整数')
}
}
for (const module of ['manual-preload-push-fixture', 'manual-preload-push-verify']) {
if (options.modules.includes(module) && !options.manualPreloadSourceReport) {
throw new Error(`${module}缺少manual-preload-source-report`)
}
}
for (const module of ['manual-pda-ready-fixture', 'manual-pda-ready-verify']) {
if (options.modules.includes(module) && !options.manualPdaSourceReport) {
throw new Error(`${module}缺少manual-pda-source-report`)
}
}
for (const module of ['manual-pda-batch-fixture', 'manual-pda-batch-verify']) {
if (options.modules.includes(module) && !options.manualPdaBatchSourceReport) {
throw new Error(`${module}缺少manual-pda-batch-source-report`)
}
}
for (const module of ['manual-loading-complete-fixture', 'manual-loading-complete-verify']) {
if (options.modules.includes(module) && !options.manualLoadingCompleteSourceReport) {
throw new Error(`${module}缺少manual-loading-complete-source-report`)
}
}
return options
@@ -716,10 +768,18 @@ export class AcceptanceRun {
for (let sequence = 2; sequence <= total; sequence += 1) {
this.marker = `${baseMarker}-WS${String(sequence).padStart(2, '0')}`
this.applyWaitSplitVariant(sequence)
const orderStepStart = this.steps.length
await this.executeModule('orders', () => orders.run(this))
if (this.modules.at(-1)?.success !== true) break
const orderRun = this.modules.at(-1)
const orderBlockingFailure = Boolean(orderRun?.fatal)
|| this.steps.slice(orderStepStart).some((step) => !step.passed && step.deploymentBlocker)
if (orderBlockingFailure) break
const measurementStepStart = this.steps.length
await this.executeModule('inbound-measurement', () => measurement.run(this))
if (this.modules.at(-1)?.success !== true || !this.captureWaitSplitFixture(sequence)) break
const measurementRun = this.modules.at(-1)
const measurementBlockingFailure = Boolean(measurementRun?.fatal)
|| this.steps.slice(measurementStepStart).some((step) => !step.passed && step.deploymentBlocker)
if (measurementBlockingFailure || !this.captureWaitSplitFixture(sequence)) break
}
} finally {
this.marker = baseMarker
+21
View File
@@ -4,6 +4,18 @@
需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。
订单造数中的 `GET /api/merchant/v1/partners/candidates?type=SHIPPER` 属于 Warehouse 正式客户候选能力。失败时订单脚本仍可使用 sender 快照继续收集后续链路证据,但 HTTP 503/`S0503` 必须保留为 deployment blocker,不能因订单、测量、分柜或装柜后续通过而降级为 coverage gap。
人工分柜采用两个可选模块,不进入默认全量入口:`manual-order-splitting-fixture.mjs` 基于本轮至少四票 `WAIT_SPLIT` 数据生成部分分配、整票拼柜和未分配对照清单;人工在 Admin 完成计划分配后,`manual-order-splitting-verify.mjs` 读取准备报告和 `loadingPlanId`,只读核对柜次范围、计划箱数及候选余量。完整命令见同一指南的“人工分柜验收”章节。
人工分柜通过后可继续执行 `manual-preload-push-fixture.mjs`:它读取分柜核验报告并冻结推送前计划版本、明细及无号模拟柜状态;人工点击“推送预装”后,再由 `manual-preload-push-verify.mjs` 只读核对 `EDITING` 预装清单、PENDING bridge、版本递增、明细零漂移和 PDA `A0404`。两个模块同样不进入默认全量入口。
预装推送通过后,`manual-pda-ready-fixture.mjs` 冻结无号、未关联订舱、不可确认且无 PDA 的基线;人工依次完成订舱 DRAFT、RELEASED 放舱、原模拟柜关联真实柜号和整份计划确认,再由 `manual-pda-ready-verify.mjs` 只读核对 RELEASED、allocation、稳定柜次/计划身份、CONFIRMED readiness 和唯一 `UNASSIGNED` PDA task。两个模块不领取任务,也不进入默认全量入口。
PDA 待领取通过后,`manual-pda-batch-fixture.mjs` 冻结任务明细、稳定箱码和库存快照基线;目标 PDA 作业员人工领取、逐箱扫码并一次提交原子 batch 后,`manual-pda-batch-verify.mjs` 只读核对作业员绑定、全量 PICKED、`AWAITING_REVIEW`、一次 CLAIM、一次 BATCH、pending/detail 一致和入库级数量收敛。HTTP 不记录逐次扫码轨迹,真机扫码仍需人工证据。
PDA 待复核通过后,`manual-loading-complete-fixture.mjs` 冻结无证据、未完成、RELEASED 订舱和 LOADING 计划基线;人工现场上传五类证据并执行一次最终完成后,`manual-loading-complete-verify.mjs` 只读核对五类真实图片、唯一 COMPLETE_LOADING、任务/计划 COMPLETED、来源入库单和统一订单 LOADED、实装分片数量守恒及货柜总览终态。
例如 `--modules system,inbound-weight-ocr` 会依次加载:
- `system.mjs`
@@ -51,6 +63,11 @@ export async function run(context) {
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
| 人工计划分柜准备与只读核验 | `manual-order-splitting-fixture.mjs`、`manual-order-splitting-verify.mjs`(可选,不进入默认全量) | `manual-order-splitting-contract.test.mjs` |
| 人工推送预装前置与推送后只读核验 | `manual-preload-push-fixture.mjs`、`manual-preload-push-verify.mjs`(可选,不进入默认全量) | `manual-preload-push-contract.test.mjs` |
| 人工订舱关联、预装确认与 PDA 待领取核验 | `manual-pda-ready-fixture.mjs`、`manual-pda-ready-verify.mjs`(可选,不进入默认全量) | `manual-pda-ready-contract.test.mjs` |
| PDA 人工领取、逐箱扫码、原子批量提交与待复核核验 | `manual-pda-batch-fixture.mjs`、`manual-pda-batch-verify.mjs`(可选,不进入默认全量) | `manual-pda-batch-contract.test.mjs` |
| 五类装柜证据、PDA 最终完成与 LOADED 终态核验 | `manual-loading-complete-fixture.mjs`、`manual-loading-complete-verify.mjs`(可选,不进入默认全量) | `manual-loading-complete-contract.test.mjs` |
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;船司查询只创建并写当前 marker 登记任务) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
| 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空;DISPATCH 前需提单号和有效 `BILL_OF_LADING` 文件) | `truck-appointments-contract.test.mjs` |
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
@@ -71,6 +88,10 @@ export async function run(context) {
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定经过本轮 `LOADED` 柜次的 `DESTINATION_CLEARANCE`、`CUSTOMS_DECLARATION`、`DELIVERY_ORDER`、`PICKUP_ORDER` 四类资料生命周期,再确认国内出仓、核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。四类资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
海外 PDA 收货统一扫描冻结箱码:`FBA` 与 `OVERSEAS` 都必须由 `POST /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve` 返回 HTTP 200、业务码 `200` 和当前柜唯一 `inboundCode`,不展示、扫描或提交 SKU。仅实收阶段分流:FBA 使用不含 `skuCode` 的 `/current/receipts/bulk`,逐箱接口必须精确返回 `A0409 FBA destination must use bulk count`;OVERSEAS 使用 `/current/receipts/carton`,bulk 必须精确返回 `A0409 OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden`。实机旧行为反证为 requestId `5e925a3d-8184-41e1-b0d2-f4e1aae86639`、traceId `62fdf603b65fd7f43ec452bb8a6ca185`。跨区广播消费只保留观察证据,不再单独形成 deployment blocker;实际 `PICKUP_READY` 候选门禁仍必须通过。
FBA 无 `ll_inbound_plan_carton` manifest 时,服务端只取完整扫描值的第一个 `#` 前缀,并在当前锁定柜中精确匹配唯一 `inboundCode`;不校验 SKU、`#` 后缀或箱序号,也不能要求前端拆分或自行决定归属。无 `#`、空前缀、未命中、跨柜或同柜多匹配必须拒绝。OVERSEAS 仍按完整箱码精确匹配真实 manifest 行。HTTP 合同使用任意 FBA 后缀验证前缀解析,覆盖上述负向与 OVERSEAS 缺 manifest,并在正负向 resolve 后回查 current 的状态、版本与收货数量零漂移。2026-08-26 最新已部署实机尚返回 `A0404 Carton mark was not found`,requestId `a716e2c2-dc10-4d07-8014-7438afb63090`、traceId `6ab8fcfcd812056ad6570d3b1652157b`;该证据保留为后端 FBA 入仓码前缀解析落盘及重新部署后的回归反证,不据此放宽成功合同。
```powershell
node scripts/admin-test-env-acceptance.mjs --gateway <development-gateway> --overseas-gateway <development-overseas-gateway> --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes
```
@@ -41,6 +41,15 @@ test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () =>
assert.doesNotMatch(palletSeed, /\bstatus\b|'AVAILABLE'/)
})
test('国内统一初始化支持真实空库且不越界创建海外业务库', () => {
assert.doesNotMatch(sql, /CREATE DATABASE IF NOT EXISTS baiyue_lastleg|USE baiyue_lastleg;/)
assert.match(sql, /IF EXISTS \(SELECT 1 FROM information_schema\.tables WHERE table_schema=DATABASE\(\)[\s\S]*?table_name='fl_ocean_booking'\) THEN[\s\S]*?UPDATE fl_container_booking container_booking[\s\S]*?JOIN fl_ocean_booking ocean_booking/)
assert.ok(
sql.indexOf("table_name='fl_ocean_booking') THEN") < sql.indexOf('CREATE TABLE IF NOT EXISTS fl_ocean_booking ('),
'empty-database backfill must be guarded before the ocean-booking baseline is created',
)
})
test('三期权限迁移删除退役权限并只保留 canonical 权限', () => {
for (const source of [sql, oceanBookingSchema]) {
assert.match(source, /DELETE FROM iam_permission WHERE id IN \(5102141,5102143,5102144,5102145\)/)
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs'
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const fixture = readFileSync(new URL('./manual-loading-complete-fixture.mjs', import.meta.url), 'utf8')
const verify = readFileSync(new URL('./manual-loading-complete-verify.mjs', import.meta.url), 'utf8')
test('manual loading-complete modules are optional and require a source report', () => {
assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-loading-complete-fixture'[^\]]*'manual-loading-complete-verify'/s)
const defaults = parseArguments([]).modules
assert.equal(defaults.includes('manual-loading-complete-fixture'), false)
assert.equal(defaults.includes('manual-loading-complete-verify'), false)
for (const module of ['manual-loading-complete-fixture', 'manual-loading-complete-verify']) {
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
'--password', 'password-value', '--modules', module,
'--manual-loading-complete-source-report', 'source.json'])
assert.deepEqual(options.modules, [module])
assert.equal(validateExecutionOptions(options), options)
assert.throws(() => validateExecutionOptions({ ...options, manualLoadingCompleteSourceReport: '' }),
/manual-loading-complete-source-report/)
}
})
test('completion preparation is read only and freezes evidence, task, plan and booking gates', () => {
assert.match(fixture, /manualPdaBatchVerification/)
assert.match(fixture, /PHOTO_STEP_1.*PHOTO_STEP_2.*PHOTO_STEP_3.*PHOTO_STEP_4.*SEAL/)
assert.match(fixture, /activeEvidence\.length === 0/)
assert.match(fixture, /finalCompletionStatus === 'IN_PROGRESS'/)
assert.match(fixture, /oceanBooking\?\.status === 'RELEASED'/)
assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
})
test('completion verifier proves five images, one completion audit and LOADED projections without writes', () => {
assert.match(verify, /task\?\.taskStatus === 'COMPLETED'/)
assert.match(verify, /activeEvidence\.length === fixture\.requiredEvidenceTypes\.length/)
assert.match(verify, /controlledImage\(download\)/)
assert.match(verify, /completeOperations\.length === 1/)
assert.match(verify, /plan\?\.status === 'COMPLETED'/)
assert.match(verify, /processStatus === 'LOADED'/)
assert.match(verify, /mainStatus === 'LOADED'/)
assert.match(verify, /splitRole === 'LOADED'/)
assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
})
@@ -0,0 +1,117 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const MODULE = 'manual-loading-complete-fixture'
const OPTIONS = { scenario: 'manual-loading-complete-fixture', deploymentBlocker: true }
const PDA = '/api/platform/v1/first-leg/pda-loading'
const SMART = '/api/platform/v1/first-leg/smart-loading'
const CONTAINERS = '/api/platform/v1/containers'
const REQUIRED_EVIDENCE_TYPES = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : [] }
async function loadSource(context) {
const report = JSON.parse(await readFile(resolve(context.options.manualLoadingCompleteSourceReport), 'utf8'))
const source = report?.createdResources?.manualPdaBatchVerification
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
const valid = sourceGateway === context.options.gateway && source?.stage === 'PDA_AWAITING_REVIEW'
&& source?.pdaTaskId && source?.loadingPlanId && source?.containerBookingId
context.assert(MODULE, '读取同环境 PDA 待复核核验报告', null, valid,
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(source?.pdaTaskId) || 'missing'}`,
OPTIONS)
return valid ? source : null
}
function taskItems(value) {
return rows(value).map((item) => ({ id: id(item.id), tenantId: id(item.tenantId),
inboundId: id(item.inboundId), inboundDetailId: id(item.inboundDetailId),
plannedPackageCount: Number(item.plannedPackageCount), pickedPackageCount: Number(item.pickedPackageCount),
itemStatus: item.itemStatus, version: Number(item.version) }))
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
}
function sourceInboundSnapshots(items) {
const values = new Map()
for (const item of rows(items)) {
const inboundId = id(item.inboundId)
const current = values.get(inboundId) || { tenantId: id(item.tenantId), inboundId, pickedPackageCount: 0 }
current.pickedPackageCount += Number(item.pickedPackageCount || 0)
values.set(inboundId, current)
}
return [...values.values()]
}
export async function run(context) {
const source = await loadSource(context)
if (!source) return
const taskId = id(source.pdaTaskId)
const taskResponse = await context.request('GET',
`${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(source.warehouseId)}`)
const task = context.apiSuccess(MODULE, '最终完成前读取 PDA 待复核任务', taskResponse, '', OPTIONS)
const beforeItems = taskItems(task?.items)
const evidenceOwner = rows(task?.items).find((item) => id(item.inboundId) === id(task?.inboundId))
|| rows(task?.items)[0]
context.assert(MODULE, '任务全部拣齐、待复核且证据归属入库单明确', taskResponse,
id(task?.id) === taskId && task?.taskStatus === 'LOADING'
&& task?.workflowStatus === 'AWAITING_REVIEW' && task?.assignment?.assigneeId
&& beforeItems.length > 0 && beforeItems.every((item) => item.itemStatus === 'PICKED'
&& item.pickedPackageCount === item.plannedPackageCount)
&& evidenceOwner?.tenantId && evidenceOwner?.inboundId,
`workflowStatus=${task?.workflowStatus || 'missing'}, items=${beforeItems.length}, evidenceTenantId=${id(evidenceOwner?.tenantId) || 'missing'}, evidenceInboundId=${id(evidenceOwner?.inboundId) || 'missing'}`,
OPTIONS)
if (!evidenceOwner?.tenantId || !evidenceOwner?.inboundId) return
const evidencePath = `/api/platform/v1/inbounds/${encodeURIComponent(evidenceOwner.inboundId)}/loading/evidence?tenantId=${encodeURIComponent(evidenceOwner.tenantId)}`
const evidenceResponse = await context.request('GET', evidencePath)
const evidence = context.apiSuccess(MODULE, '上传五类证据前读取空基线', evidenceResponse, '', OPTIONS)
const activeEvidence = rows(evidence?.evidences).filter((item) => item.status !== 'VOID')
context.assert(MODULE, '本轮最终完成前尚未上传 ACTIVE 装柜证据', evidenceResponse,
activeEvidence.length === 0,
`activeEvidence=${activeEvidence.map((item) => item.evidenceType).join(',') || 'none'}`, OPTIONS)
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(source.loadingPlanId)}`)
const plan = context.apiSuccess(MODULE, '最终完成前读取运行中装柜计划', planResponse, '', OPTIONS)
context.assert(MODULE, '计划保持 LOADING 且尚未最终完成', planResponse,
id(plan?.id) === id(source.loadingPlanId) && plan?.status === 'LOADING'
&& plan?.finalCompletionStatus === 'IN_PROGRESS',
`status=${plan?.status || 'missing'}, finalCompletionStatus=${plan?.finalCompletionStatus || 'missing'}, version=${plan?.version ?? 'missing'}`,
OPTIONS)
const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`)
const operations = context.apiSuccess(MODULE, '最终完成前读取任务审计', operationsResponse, '', OPTIONS)
context.assert(MODULE, '完成前只有一次 CLAIM/BATCH 且无 COMPLETE_LOADING', operationsResponse,
rows(operations).filter((item) => item.actionType === 'CLAIM').length === 1
&& rows(operations).filter((item) => item.actionType === 'BATCH').length === 1
&& !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)),
`actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}`, OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(source.containerBookingId)}`)
const container = context.apiSuccess(MODULE, '最终完成前回查 RELEASED 订舱柜次', containerResponse, '', OPTIONS)
const bookingResponse = container?.oceanBookingId
? await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(container.oceanBookingId)}`)
: containerResponse
const booking = context.apiSuccess(MODULE, '最终完成前回查订舱门禁', bookingResponse, '', OPTIONS)
context.assert(MODULE, '完成前真实柜号与 RELEASED 订舱仍有效', bookingResponse,
id(container?.id) === id(source.containerBookingId) && container?.containerNo === source.containerNo
&& booking?.oceanBooking?.status === 'RELEASED',
`containerNo=${container?.containerNo || 'missing'}, oceanBookingId=${id(container?.oceanBookingId) || 'missing'}, bookingStatus=${booking?.oceanBooking?.status || 'missing'}`,
OPTIONS)
context.createdResources.manualLoadingCompleteFixture = {
schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(),
stage: 'MANUAL_EVIDENCE_AND_COMPLETE_READY', pdaTaskId: taskId,
loadingPlanId: id(source.loadingPlanId), containerBookingId: id(source.containerBookingId),
cabinetNo: source.cabinetNo, containerNo: source.containerNo, warehouseId: id(source.warehouseId),
evidenceTenantId: id(evidenceOwner.tenantId), evidenceInboundId: id(evidenceOwner.inboundId),
requiredEvidenceTypes: REQUIRED_EVIDENCE_TYPES, beforeTaskVersion: Number(task.version),
beforePlanVersion: Number(plan?.version), beforeItems,
sourceInbounds: sourceInboundSnapshots(task.items),
instructions: [
'在 PDA 当前任务中依次现场拍摄空仓、半仓、满仓、锁仓和带锁码照五类证据。',
'五类分别对应 PHOTO_STEP_1、PHOTO_STEP_2、PHOTO_STEP_3、PHOTO_STEP_4、SEAL;SEAL 必须填写非空锁码。',
'确认五类证据均为 ACTIVE 后,只点击一次最终完成;不要调用旧入库单兼容完成入口。',
'完成成功后任务、计划和所有来源入库单应进入唯一终态,立即运行 manual-loading-complete-verify。',
],
}
}
@@ -0,0 +1,163 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const MODULE = 'manual-loading-complete-verify'
const OPTIONS = { scenario: 'manual-loading-complete-readonly-verification', deploymentBlocker: true }
const PDA = '/api/platform/v1/first-leg/pda-loading'
const SMART = '/api/platform/v1/first-leg/smart-loading'
const CONTAINERS = '/api/platform/v1/containers'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : [] }
async function loadFixture(context) {
const report = JSON.parse(await readFile(resolve(context.options.manualLoadingCompleteSourceReport), 'utf8'))
const fixture = report?.createdResources?.manualLoadingCompleteFixture
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1
&& fixture?.stage === 'MANUAL_EVIDENCE_AND_COMPLETE_READY' && fixture?.pdaTaskId
&& Array.isArray(fixture?.requiredEvidenceTypes) && fixture.requiredEvidenceTypes.length === 5
&& Array.isArray(fixture?.sourceInbounds) && fixture.sourceInbounds.length > 0
&& fixture?.evidenceTenantId && fixture?.evidenceInboundId
context.assert(MODULE, '读取同环境装柜最终完成准备报告', null, valid,
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(fixture?.pdaTaskId) || 'missing'}`,
OPTIONS)
return valid ? fixture : null
}
function itemIdentity(value) {
return { id: id(value.id), tenantId: id(value.tenantId), inboundId: id(value.inboundId),
inboundDetailId: id(value.inboundDetailId), plannedPackageCount: Number(value.plannedPackageCount),
pickedPackageCount: Number(value.pickedPackageCount), itemStatus: value.itemStatus }
}
function controlledImage(download) {
const type = String(download?.contentType || '').toLowerCase()
return download?.http === 200 && download?.bytes?.length > 0
&& type.startsWith('image/') && !type.includes('json') && !type.includes('html')
}
export async function run(context) {
const fixture = await loadFixture(context)
if (!fixture) return
const taskId = id(fixture.pdaTaskId)
const taskResponse = await context.request('GET',
`${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
const task = context.apiSuccess(MODULE, '读取 PDA 最终完成任务', taskResponse, '', OPTIONS)
const afterItems = rows(task?.items).map(itemIdentity)
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
const beforeItems = fixture.beforeItems.map(itemIdentity)
context.assert(MODULE, '最终完成后任务与 workflow 同为 COMPLETED 且明细身份不漂移', taskResponse,
id(task?.id) === taskId && id(task?.loadingPlanId) === id(fixture.loadingPlanId)
&& id(task?.containerBookingId) === id(fixture.containerBookingId)
&& task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED'
&& Number(task?.version) > Number(fixture.beforeTaskVersion)
&& JSON.stringify(afterItems) === JSON.stringify(beforeItems),
`taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, beforeVersion=${fixture.beforeTaskVersion}, afterVersion=${task?.version ?? 'missing'}`,
OPTIONS)
const evidencePath = `/api/platform/v1/inbounds/${encodeURIComponent(fixture.evidenceInboundId)}/loading/evidence?tenantId=${encodeURIComponent(fixture.evidenceTenantId)}`
const evidenceResponse = await context.request('GET', evidencePath)
const evidence = context.apiSuccess(MODULE, '读取 LOADED 后五类不可变证据', evidenceResponse, '', OPTIONS)
const activeEvidence = rows(evidence?.evidences).filter((item) => item.status !== 'VOID')
const evidenceComplete = fixture.requiredEvidenceTypes.every((type) =>
activeEvidence.filter((item) => item.evidenceType === type).length === 1)
&& activeEvidence.length === fixture.requiredEvidenceTypes.length
&& activeEvidence.some((item) => item.evidenceType === 'SEAL' && String(item.sealNo || '').trim())
context.assert(MODULE, '五类 ACTIVE 证据各一份且 SEAL 锁码有效', evidenceResponse,
evidenceComplete,
`types=${activeEvidence.map((item) => item.evidenceType).join(',') || 'none'}, sealNo=${activeEvidence.find((item) => item.evidenceType === 'SEAL')?.sealNo || 'missing'}`,
OPTIONS)
for (const item of activeEvidence) {
const path = String(item.previewUrl || '')
const controlled = path.startsWith('/api/') && !path.startsWith('//') && !path.includes('://')
const download = controlled ? await context.requestBinary('GET', path) : null
context.assert(MODULE, `${item.evidenceType} 证据可鉴权下载真实图片`, download,
controlled && controlledImage(download),
`evidenceId=${id(item.id)}, path=${path || 'missing'}, bytes=${download?.bytes?.length ?? 0}, contentType=${download?.contentType || 'missing'}`,
OPTIONS)
}
const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`)
const operations = context.apiSuccess(MODULE, '读取最终完成不可变操作审计', operationsResponse, '', OPTIONS)
const completeOperations = rows(operations).filter((item) => item.actionType === 'COMPLETE_LOADING')
context.assert(MODULE, '最终主链恰有一次 COMPLETE_LOADING 且保留原 CLAIM/BATCH', operationsResponse,
rows(operations).filter((item) => item.actionType === 'CLAIM').length === 1
&& rows(operations).filter((item) => item.actionType === 'BATCH').length === 1
&& completeOperations.length === 1 && completeOperations[0]?.operationId
&& completeOperations[0]?.operatorId && completeOperations[0]?.operatorName
&& completeOperations[0]?.createdAt,
`actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}, completeCount=${completeOperations.length}`,
OPTIONS)
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(fixture.loadingPlanId)}`)
const plan = context.apiSuccess(MODULE, '读取最终完成装柜计划', planResponse, '', OPTIONS)
context.assert(MODULE, '原装柜计划进入 COMPLETED 且版本递增', planResponse,
id(plan?.id) === id(fixture.loadingPlanId) && plan?.status === 'COMPLETED'
&& Number(plan?.version) > Number(fixture.beforePlanVersion),
`status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}`,
OPTIONS)
const orderFacts = []
for (const source of fixture.sourceInbounds || []) {
const inboundResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(source.inboundId)}?tenantId=${encodeURIComponent(source.tenantId)}`)
const inbound = context.apiSuccess(MODULE, `${source.inboundId} 回查 LOADED 入库终态`, inboundResponse, '', OPTIONS)
const orderId = id(inbound?.summary?.orderId)
context.assert(MODULE, `${source.inboundId} 入库状态和完成历史正确`, inboundResponse,
inbound?.summary?.processStatus === 'LOADED' && orderId
&& rows(inbound?.statusHistory).some((item) => item.action === 'COMPLETE_LOADING'
&& item.fromStatus === 'LOADING' && item.toStatus === 'LOADED'
&& item.operatorName),
`status=${inbound?.summary?.processStatus || 'missing'}, orderId=${orderId || 'missing'}, histories=${rows(inbound?.statusHistory).length}`,
OPTIONS)
if (!orderId) continue
const orderResponse = await context.request('GET',
`/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(source.tenantId)}`)
const order = context.apiSuccess(MODULE, `${orderId} 回查统一订单终态`, orderResponse, '', OPTIONS)
context.assert(MODULE, `${orderId} 统一订单详情投影为 LOADED`, orderResponse,
id(order?.summary?.orderId) === orderId && order?.summary?.mainStatus === 'LOADED',
`mainStatus=${order?.summary?.mainStatus || 'missing'}, orderNo=${order?.summary?.orderNo || 'missing'}`,
OPTIONS)
orderFacts.push({ tenantId: id(source.tenantId), inboundId: id(source.inboundId), orderId,
orderNo: order?.summary?.orderNo || null })
}
const tenantId = id(fixture.sourceInbounds?.[0]?.tenantId)
const slicesResponse = await context.request('GET',
`/api/platform/v1/orders/container-bookings/${encodeURIComponent(fixture.containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`)
const slices = context.apiSuccess(MODULE, '回查柜次最终实装分片', slicesResponse, '', OPTIONS)
const loadedSlices = rows(slices).filter((item) => item.splitRole === 'LOADED'
&& id(item.containerBookingId) === id(fixture.containerBookingId)
&& id(item.loadingPlanId) === id(fixture.loadingPlanId))
const pickedPackages = fixture.sourceInbounds.reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0)
context.assert(MODULE, '最终实装分片唯一且箱数等于 PDA 已拣总数', slicesResponse,
loadedSlices.length > 0
&& new Set(loadedSlices.map((item) => id(item.sourceOrderId))).size === loadedSlices.length
&& loadedSlices.reduce((sum, item) => sum + Number(item.packageCount || 0), 0) === pickedPackages,
`loadedSlices=${loadedSlices.length}, loadedPackages=${loadedSlices.reduce((sum, item) => sum + Number(item.packageCount || 0), 0)}, pickedPackages=${pickedPackages}`,
OPTIONS)
const overviewResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(fixture.containerBookingId)}/overview`)
const overview = context.apiSuccess(MODULE, '回查货柜最终总览', overviewResponse, '', OPTIONS)
const photoCounts = new Map(rows(overview?.pda?.photoCounts).map((item) => [item.evidenceType, Number(item.count)]))
context.assert(MODULE, '货柜总览汇总 COMPLETED PDA 和五类 ACTIVE 证据', overviewResponse,
id(overview?.loadingPlanId) === id(fixture.loadingPlanId)
&& id(overview?.pda?.taskId) === taskId
&& overview?.pda?.taskStatus === 'COMPLETED' && overview?.pda?.workflowStatus === 'COMPLETED'
&& fixture.requiredEvidenceTypes.every((type) => Number(photoCounts.get(type)) >= 1)
&& rows(overview?.pda?.photos).filter((item) => item.status === 'ACTIVE').length === 5,
`taskStatus=${overview?.pda?.taskStatus || 'missing'}, workflowStatus=${overview?.pda?.workflowStatus || 'missing'}, photoCounts=${JSON.stringify(Object.fromEntries(photoCounts))}`,
OPTIONS)
context.createdResources.manualLoadingCompleteVerification = {
sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'LOADED',
pdaTaskId: taskId, loadingPlanId: id(fixture.loadingPlanId),
containerBookingId: id(fixture.containerBookingId), cabinetNo: fixture.cabinetNo,
containerNo: fixture.containerNo, completeOperationId: completeOperations[0]?.operationId || null,
evidenceInboundId: id(fixture.evidenceInboundId), evidenceIds: activeEvidence.map((item) => id(item.id)),
loadedSliceCount: loadedSlices.length, loadedPackageCount: pickedPackages, orders: orderFacts,
terminalNote: '国内装柜人工验收链已到 LOADED;后续报关、国内出仓和海外接柜属于后续节点。',
}
}
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs'
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const fixture = readFileSync(new URL('./manual-order-splitting-fixture.mjs', import.meta.url), 'utf8')
const verify = readFileSync(new URL('./manual-order-splitting-verify.mjs', import.meta.url), 'utf8')
test('manual splitting modules are optional registered entries with a WAIT_SPLIT producer dependency', () => {
assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-order-splitting-fixture'[^\]]*'manual-order-splitting-verify'/s)
assert.match(entry, /\['manual-order-splitting-fixture', \['inbound-measurement'\]\]/)
assert.match(entry, /--manual-split-fixture-report/)
assert.match(entry, /--manual-split-plan-id/)
assert.match(entry, /manual-order-splitting-fixture[^\n]+waitSplitCount < 4/)
})
test('fixture defaults to six orders while verifier requires an explicit report and plan id', () => {
const defaultModules = parseArguments([]).modules
assert.equal(defaultModules.includes('manual-order-splitting-fixture'), false)
assert.equal(defaultModules.includes('manual-order-splitting-verify'), false)
const fixtureOptions = parseArguments([
'--gateway', 'http://127.0.0.1:9000', '--username', 'admin', '--password', 'password-value',
'--modules', 'manual-order-splitting-fixture',
])
assert.deepEqual(fixtureOptions.modules, [
'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'manual-order-splitting-fixture',
])
assert.equal(fixtureOptions.waitSplitCount, 6)
assert.equal(validateExecutionOptions(fixtureOptions), fixtureOptions)
const verifyOptions = parseArguments([
'--gateway', 'http://127.0.0.1:9000', '--username', 'admin', '--password', 'password-value',
'--modules', 'manual-order-splitting-verify', '--manual-split-fixture-report', 'fixture.json',
'--manual-split-plan-id', '123',
])
assert.deepEqual(verifyOptions.modules, ['manual-order-splitting-verify'])
assert.equal(validateExecutionOptions(verifyOptions), verifyOptions)
assert.throws(() => validateExecutionOptions({ ...verifyOptions, manualSplitPlanId: '' }),
/manual-split-plan-id/)
assert.throws(() => validateExecutionOptions({ ...fixtureOptions, waitSplitCount: 3 }),
/wait-split-count至少为4/)
})
test('fixture prepares a partial, two full and one untouched scenario without performing split writes', () => {
assert.match(fixture, /role: 'PARTIAL'/)
assert.match(fixture, /role: `FULL_\$\{index \+ 1\}`/)
assert.match(fixture, /role: 'UNASSIGNED_CONTROL'/)
assert.match(fixture, /WAIT_SPLIT_READY_FOR_MANUAL_ASSIGNMENT/)
assert.doesNotMatch(fixture, /context\.request\(['"]POST/)
})
test('verifier is read only and checks plan, container, allocations and remaining candidates', () => {
assert.match(verify, /context\.request\('GET', `\$\{SMART\}\/plans/)
assert.match(verify, /context\.request\('GET', `\$\{CONTAINERS\}/)
assert.match(verify, /inbound-candidates\?bookingId=/)
assert.match(verify, /sourceGateway[\s\S]*sameGateway/)
assert.match(verify, /候选列表未出现异渠道或异仓入库单/)
assert.match(verify, /expectedAllocatedPackageCount/)
assert.match(verify, /expectedRemainingPackageCount/)
assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
})
@@ -0,0 +1,78 @@
const MODULE = 'manual-order-splitting-fixture'
const OPTIONS = { scenario: 'manual-order-splitting-fixture', deploymentBlocker: true }
function id(value) { return value == null ? '' : String(value) }
function positiveInteger(value) { return Number.isInteger(Number(value)) && Number(value) > 0 }
function normalizedFixture(value) {
return {
sequence: Number(value.sequence), tenantId: id(value.tenantId), orderId: id(value.orderId),
orderNo: value.orderNo || null, inboundId: id(value.inboundId), inboundCode: value.inboundCode || null,
warehouseId: id(value.receivingWarehouseId), warehouseName: value.receivingWarehouseName || null,
channelCode: value.channelCode || null, channelName: value.channelName || null,
totalPackageCount: Number(value.totalPackageCount), destinationType: value.destinationType || null,
fulfillmentType: value.fulfillmentType || null, containerMode: value.containerMode || null,
}
}
export async function run(context) {
const fixtures = (context.createdResources?.waitSplitFixtures || []).map(normalizedFixture)
const valid = fixtures.length >= 4 && fixtures.every((item) => item.tenantId && item.orderId
&& item.inboundId && item.warehouseId && item.channelCode && positiveInteger(item.totalPackageCount))
context.assert(MODULE, '至少取得四票本轮 WAIT_SPLIT 人工分柜数据', null, valid,
`count=${fixtures.length}, invalid=${fixtures.filter((item) => !item.tenantId || !item.orderId
|| !item.inboundId || !item.warehouseId || !item.channelCode || !positiveInteger(item.totalPackageCount)).length}`,
OPTIONS)
if (!valid) return
const tenantIds = new Set(fixtures.map((item) => item.tenantId))
const warehouseIds = new Set(fixtures.map((item) => item.warehouseId))
const channelCodes = new Set(fixtures.map((item) => item.channelCode))
const sameScope = tenantIds.size === 1 && warehouseIds.size === 1 && channelCodes.size === 1
context.assert(MODULE, '人工分柜正向样本固定同一客户、起运仓和渠道', null, sameScope,
`tenantIds=${[...tenantIds].join(',')}, warehouseIds=${[...warehouseIds].join(',')}, channelCodes=${[...channelCodes].join(',')}`,
OPTIONS)
if (!sameScope) return
const byPackages = [...fixtures].sort((left, right) => right.totalPackageCount - left.totalPackageCount
|| left.sequence - right.sequence)
const partial = byPackages.find((item) => item.totalPackageCount >= 2)
const remaining = fixtures.filter((item) => item.inboundId !== partial?.inboundId)
const full = remaining.slice(0, 2)
const control = remaining[2]
const scenarioReady = Boolean(partial && full.length === 2 && control)
context.assert(MODULE, '人工分柜场景包含部分分配、两票整票拼柜和未分配对照', null, scenarioReady,
`partial=${partial?.inboundCode || 'missing'}, full=${full.map((item) => item.inboundCode).join(',') || 'missing'}, control=${control?.inboundCode || 'missing'}`,
OPTIONS)
if (!scenarioReady) return
const assignments = [
{ role: 'PARTIAL', ...partial, expectedAllocatedPackageCount: partial.totalPackageCount - 1,
expectedRemainingPackageCount: 1 },
...full.map((item, index) => ({ role: `FULL_${index + 1}`, ...item,
expectedAllocatedPackageCount: item.totalPackageCount, expectedRemainingPackageCount: 0 })),
]
const manifest = {
schemaVersion: 1, marker: context.marker, stage: 'WAIT_SPLIT_READY_FOR_MANUAL_ASSIGNMENT',
tenantId: fixtures[0].tenantId, warehouseId: fixtures[0].warehouseId,
warehouseName: fixtures[0].warehouseName, channelCode: fixtures[0].channelCode,
channelName: fixtures[0].channelName,
adminEntry: '/container-loading',
instructions: [
'在智能装柜页面创建一个同仓同渠道的内部模拟柜。',
'创建智能装柜计划,并严格按 assignments 的 expectedAllocatedPackageCount 分配。',
'保留 unassignedControl 不加入计划,用于核对候选剩余与误选保护。',
'人工操作后记录 loadingPlanId,并运行 manual-order-splitting-verify 只读核验。',
],
assignments,
unassignedControl: { role: 'UNASSIGNED_CONTROL', ...control,
expectedAllocatedPackageCount: 0, expectedRemainingPackageCount: control.totalPackageCount },
allFixtures: fixtures,
}
context.createdResources.manualOrderSplittingFixture = manifest
context.assert(MODULE, '人工分柜清单数量守恒', null,
assignments.every((item) => item.totalPackageCount
=== item.expectedAllocatedPackageCount + item.expectedRemainingPackageCount),
`assignments=${assignments.length}, plannedPackages=${assignments.reduce((sum, item) => sum + item.expectedAllocatedPackageCount, 0)}`,
OPTIONS)
}
@@ -0,0 +1,97 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const MODULE = 'manual-order-splitting-verify'
const OPTIONS = { scenario: 'manual-order-splitting-readonly-verification', deploymentBlocker: true }
const SMART = '/api/platform/v1/first-leg/smart-loading'
const CONTAINERS = '/api/platform/v1/containers'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : [] }
async function loadManifest(context) {
const path = resolve(context.options.manualSplitFixtureReport)
const report = JSON.parse(await readFile(path, 'utf8'))
const manifest = report?.createdResources?.manualOrderSplittingFixture
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
const sameGateway = sourceGateway && sourceGateway === context.options.gateway
context.assert(MODULE, '读取人工分柜准备报告', null,
manifest?.schemaVersion === 1 && manifest?.stage === 'WAIT_SPLIT_READY_FOR_MANUAL_ASSIGNMENT'
&& Array.isArray(manifest.assignments) && manifest.assignments.length === 3 && sameGateway,
`sourceMarker=${report?.marker || 'missing'}, schemaVersion=${manifest?.schemaVersion ?? 'missing'}, assignments=${manifest?.assignments?.length ?? 'missing'}, sameGateway=${Boolean(sameGateway)}`,
OPTIONS)
return sameGateway ? manifest : null
}
export async function run(context) {
const manifest = await loadManifest(context)
if (!manifest?.assignments?.length) return
const planId = id(context.options.manualSplitPlanId)
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`)
const plan = context.apiSuccess(MODULE, '读取人工操作后的智能装柜计划', planResponse,
`loadingPlanId=${planId}`, OPTIONS)
if (!plan) return
const bookingId = id(plan.bookingId || plan.containerBookingId)
const planItems = rows(plan.items)
context.assert(MODULE, '智能装柜计划身份与状态可继续人工验收', planResponse,
id(plan.id) === planId && bookingId && ['EDITING', 'LOADING'].includes(plan.status),
`planId=${id(plan.id) || 'missing'}, bookingId=${bookingId || 'missing'}, status=${plan.status || 'missing'}, version=${plan.version ?? 'missing'}`,
OPTIONS)
if (!bookingId) return
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(bookingId)}`)
const container = context.apiSuccess(MODULE, '读取人工分柜关联柜次', containerResponse, '', OPTIONS)
context.assert(MODULE, '柜次保持准备清单的仓库和渠道范围', containerResponse,
id(container?.id) === bookingId && id(container?.warehouseId) === id(manifest.warehouseId)
&& container?.channelCode === manifest.channelCode,
`containerId=${id(container?.id) || 'missing'}, warehouseId=${id(container?.warehouseId) || 'missing'}, channelCode=${container?.channelCode || 'missing'}`,
OPTIONS)
const expectedByInbound = new Map(manifest.assignments.map((item) => [id(item.inboundId), item]))
for (const expected of manifest.assignments) {
const matches = planItems.filter((item) => id(item.inboundId) === id(expected.inboundId))
const allocated = matches.reduce((sum, item) => sum + Number(item.packageCount || 0), 0)
context.assert(MODULE, `${expected.role} 按清单完成计划分配`, planResponse,
matches.length >= 1 && allocated === Number(expected.expectedAllocatedPackageCount),
`inboundCode=${expected.inboundCode || 'missing'}, expected=${expected.expectedAllocatedPackageCount}, actual=${allocated}, itemCount=${matches.length}`,
OPTIONS)
}
const foreignItems = planItems.filter((item) => !expectedByInbound.has(id(item.inboundId)))
const controlItems = planItems.filter((item) => id(item.inboundId) === id(manifest.unassignedControl?.inboundId))
context.assert(MODULE, '计划未混入清单外或未分配对照入库单', planResponse,
foreignItems.length === 0 && controlItems.length === 0,
`foreignInboundIds=${foreignItems.map((item) => id(item.inboundId)).join(',') || 'none'}, controlItemCount=${controlItems.length}`,
OPTIONS)
const candidatesPath = `${SMART}/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(manifest.warehouseId)}`
const candidatesResponse = await context.request('GET', candidatesPath)
const candidates = context.apiSuccess(MODULE, '回查人工分柜后的入仓码候选余量', candidatesResponse, '', OPTIONS)
const candidateRows = rows(candidates)
const candidateScopeValid = candidateRows.every((item) => (!item.channelCode
|| item.channelCode === manifest.channelCode)
&& (!item.warehouseId || id(item.warehouseId) === id(manifest.warehouseId)))
context.assert(MODULE, '候选列表未出现异渠道或异仓入库单', candidatesResponse,
candidateScopeValid,
`candidateCount=${candidateRows.length}, channelCode=${manifest.channelCode}, warehouseId=${manifest.warehouseId}`,
OPTIONS)
for (const expected of [...manifest.assignments, manifest.unassignedControl]) {
const candidate = candidateRows.find((item) => id(item.inboundId) === id(expected.inboundId))
const actualRemaining = candidate ? Number(candidate.availablePackageCount) : 0
context.assert(MODULE, `${expected.role} 分配后剩余箱数正确`, candidatesResponse,
actualRemaining === Number(expected.expectedRemainingPackageCount),
`inboundCode=${expected.inboundCode || 'missing'}, expectedRemaining=${expected.expectedRemainingPackageCount}, actualRemaining=${actualRemaining}`,
OPTIONS)
}
context.createdResources.manualOrderSplittingVerification = {
sourceMarker: manifest.marker, verifiedAt: new Date().toISOString(), loadingPlanId: planId,
containerBookingId: bookingId, tenantId: id(manifest.tenantId), warehouseId: id(manifest.warehouseId),
channelCode: manifest.channelCode, planStatus: plan.status, planVersion: Number(plan.version),
verifiedAssignments: manifest.assignments.map((item) => ({ role: item.role, orderId: id(item.orderId),
inboundId: id(item.inboundId), expectedAllocatedPackageCount: Number(item.expectedAllocatedPackageCount) })),
note: '本模块只读核验计划分柜;父子订单在 PDA 实装结算后另行核验。',
}
}
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs'
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const fixture = readFileSync(new URL('./manual-pda-batch-fixture.mjs', import.meta.url), 'utf8')
const verify = readFileSync(new URL('./manual-pda-batch-verify.mjs', import.meta.url), 'utf8')
test('manual PDA batch modules are optional and require a source report', () => {
assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-pda-batch-fixture'[^\]]*'manual-pda-batch-verify'/s)
const defaults = parseArguments([]).modules
assert.equal(defaults.includes('manual-pda-batch-fixture'), false)
assert.equal(defaults.includes('manual-pda-batch-verify'), false)
for (const module of ['manual-pda-batch-fixture', 'manual-pda-batch-verify']) {
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
'--password', 'password-value', '--modules', module, '--manual-pda-batch-source-report', 'source.json'])
assert.deepEqual(options.modules, [module])
assert.equal(validateExecutionOptions(options), options)
assert.throws(() => validateExecutionOptions({ ...options, manualPdaBatchSourceReport: '' }),
/manual-pda-batch-source-report/)
}
})
test('PDA batch preparation is read only and preserves stable carton scan instructions', () => {
assert.match(fixture, /manualPdaReadyVerification/)
assert.match(fixture, /boxCodes\.length >= item\.plannedPackageCount/)
assert.match(fixture, /scanBoxCodes: rows\(item\.boxCodes\)/)
assert.match(fixture, /inventory-snapshot/)
assert.match(fixture, /不存在 CLAIM、BATCH、REVIEW 或 COMPLETE_LOADING/)
assert.match(fixture, /逐次扫码动作需保留真机人工证据/)
assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
})
test('post-batch verifier proves one claim, one batch and AWAITING_REVIEW without writes', () => {
assert.match(verify, /workflowStatus === 'AWAITING_REVIEW'/)
assert.match(verify, /itemStatus === 'PICKED'/)
assert.match(verify, /claimOperations\.length === 1 && batchOperations\.length === 1/)
assert.match(verify, /pendingItemCount\) === 0/)
assert.match(verify, /expectedCount\) === Number\(projection\?\.pickedCount\)/)
assert.match(verify, /HTTP 不记录逐次扫码轨迹/)
assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
})
@@ -0,0 +1,105 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const MODULE = 'manual-pda-batch-fixture'
const OPTIONS = { scenario: 'manual-pda-batch-fixture', deploymentBlocker: true }
const PDA = '/api/platform/v1/first-leg/pda-loading'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : [] }
async function loadSource(context) {
const report = JSON.parse(await readFile(resolve(context.options.manualPdaBatchSourceReport), 'utf8'))
const source = report?.createdResources?.manualPdaReadyVerification
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
const valid = sourceGateway === context.options.gateway && source?.stage === 'PDA_UNASSIGNED'
&& source?.pdaTaskId && source?.loadingPlanId && source?.containerBookingId
context.assert(MODULE, '读取同环境 PDA 待领取核验报告', null, valid,
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(source?.pdaTaskId) || 'missing'}`,
OPTIONS)
return valid ? source : null
}
function taskItemSnapshot(items) {
return rows(items).map((item) => ({
id: id(item.id), loadingPlanItemId: id(item.loadingPlanItemId), tenantId: id(item.tenantId),
inboundId: id(item.inboundId), inboundDetailId: id(item.inboundDetailId),
orderNo: item.orderNo || null, inboundCode: item.inboundCode || null, cargoName: item.cargoName || null,
boxCodes: rows(item.boxCodes).map(String),
scanBoxCodes: rows(item.boxCodes).map(String).slice(0, Number(item.plannedPackageCount)),
plannedPackageCount: Number(item.plannedPackageCount),
})).sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
}
export async function run(context) {
const source = await loadSource(context)
if (!source) return
const taskId = id(source.pdaTaskId)
const taskResponse = await context.request('GET',
`${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(source.warehouseId)}`)
const task = context.apiSuccess(MODULE, 'PDA 人工作业前读取稳定任务详情', taskResponse, '', OPTIONS)
const taskItems = taskItemSnapshot(task?.items)
const scanBoxCodes = taskItems.flatMap((item) => item.scanBoxCodes)
const cartonCodesReady = taskItems.length > 0 && taskItems.every((item) => item.id && item.inboundId
&& item.plannedPackageCount > 0 && item.boxCodes.length >= item.plannedPackageCount
&& item.scanBoxCodes.length === item.plannedPackageCount
&& new Set(item.boxCodes).size === item.boxCodes.length)
&& new Set(scanBoxCodes).size === scanBoxCodes.length
context.assert(MODULE, '待领取任务下发稳定箱码且计划箱数可逐箱扫描', taskResponse,
id(task?.id) === taskId && id(task?.loadingPlanId) === id(source.loadingPlanId)
&& id(task?.containerBookingId) === id(source.containerBookingId)
&& task?.taskStatus === 'LOADING' && task?.workflowStatus === 'UNASSIGNED'
&& task?.assignment == null && cartonCodesReady,
`workflowStatus=${task?.workflowStatus || 'missing'}, items=${taskItems.length}, plannedPackages=${taskItems.reduce((sum, item) => sum + item.plannedPackageCount, 0)}, boxCodes=${taskItems.reduce((sum, item) => sum + item.boxCodes.length, 0)}`,
OPTIONS)
if (!cartonCodesReady || task?.workflowStatus !== 'UNASSIGNED') return
const pendingResponse = await context.request('GET',
`${PDA}/tasks/pending?keyword=${encodeURIComponent(source.containerNo)}`)
const pending = context.apiSuccess(MODULE, '作业前读取当前操作员 PDA 待办', pendingResponse, '', OPTIONS)
const pendingTask = rows(pending).find((item) => id(item.taskId) === taskId)
context.assert(MODULE, '未领取任务在 PDA 待办中唯一可见', pendingResponse,
Boolean(pendingTask) && pendingTask.workflowStatus === 'UNASSIGNED'
&& Number(pendingTask.pendingItemCount) === taskItems.length,
`matches=${rows(pending).filter((item) => id(item.taskId) === taskId).length}, pendingItemCount=${pendingTask?.pendingItemCount ?? 'missing'}`,
OPTIONS)
const inventoryResponse = await context.request('GET',
`${PDA}/tasks/${encodeURIComponent(taskId)}/inventory-snapshot?warehouseId=${encodeURIComponent(source.warehouseId)}`)
const inventory = context.apiSuccess(MODULE, '作业前读取实时库存快照基线', inventoryResponse, '', OPTIONS)
const inventoryReady = id(inventory?.taskId) === taskId
&& id(inventory?.loadingPlanId) === id(source.loadingPlanId)
&& id(inventory?.containerBookingId) === id(source.containerBookingId)
&& Number(inventory?.taskVersion) === Number(task?.version)
&& typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64
&& rows(inventory?.inbounds).length > 0
context.assert(MODULE, '库存快照与待领取任务版本和四元身份一致', inventoryResponse,
inventoryReady,
`taskVersion=${task?.version ?? 'missing'}, snapshotTaskVersion=${inventory?.taskVersion ?? 'missing'}, inboundCount=${rows(inventory?.inbounds).length}`,
OPTIONS)
const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`)
const operations = context.apiSuccess(MODULE, '作业前读取 PDA 操作历史', operationsResponse, '', OPTIONS)
context.assert(MODULE, '作业前不存在 CLAIM、BATCH、REVIEW 或 COMPLETE_LOADING', operationsResponse,
rows(operations).every((item) => !['CLAIM', 'BATCH', 'REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)),
`actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}`,
OPTIONS)
context.createdResources.manualPdaBatchFixture = {
schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(),
stage: 'MANUAL_PDA_CLAIM_AND_BATCH_READY', pdaTaskId: taskId,
loadingPlanId: id(source.loadingPlanId), containerBookingId: id(source.containerBookingId),
cabinetNo: source.cabinetNo, containerNo: source.containerNo, tenantId: id(source.tenantId),
warehouseId: id(source.warehouseId), beforeTaskVersion: Number(task.version), taskItems,
inventoryBaseline: rows(inventory?.inbounds).map((item) => ({ inboundId: id(item.inboundId),
availablePackageCount: Number(item.availablePackageCount), resourceCount: rows(item.resources).length })),
instructions: [
'使用目标 PDA 作业员进入待办并领取本任务,确认任务进入 IN_PROGRESS。',
'按 taskItems.scanBoxCodes 在真机逐箱扫描;每个箱码只扫描一次,处理完全部计划箱数。',
'点击提交时由 PDA 重新获取库存快照,并只发送一次原子 batch;不要使用逐明细兼容接口。',
'提交成功停在 AWAITING_REVIEW,不上传装柜证据、不复核、不执行最终完成。',
'HTTP 只能核验箱码下发和服务端数量,逐次扫码动作需保留真机人工证据。',
],
}
}
@@ -0,0 +1,111 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const MODULE = 'manual-pda-batch-verify'
const OPTIONS = { scenario: 'manual-pda-batch-readonly-verification', deploymentBlocker: true }
const PDA = '/api/platform/v1/first-leg/pda-loading'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : [] }
async function loadFixture(context) {
const report = JSON.parse(await readFile(resolve(context.options.manualPdaBatchSourceReport), 'utf8'))
const fixture = report?.createdResources?.manualPdaBatchFixture
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1
&& fixture?.stage === 'MANUAL_PDA_CLAIM_AND_BATCH_READY' && fixture?.pdaTaskId
&& Array.isArray(fixture?.taskItems) && fixture.taskItems.length > 0
context.assert(MODULE, '读取同环境 PDA 人工作业准备报告', null, valid,
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(fixture?.pdaTaskId) || 'missing'}`,
OPTIONS)
return valid ? fixture : null
}
function stableItem(value) {
return { id: id(value.id), loadingPlanItemId: id(value.loadingPlanItemId), tenantId: id(value.tenantId),
inboundId: id(value.inboundId), inboundDetailId: id(value.inboundDetailId),
boxCodes: rows(value.boxCodes).map(String), plannedPackageCount: Number(value.plannedPackageCount) }
}
export async function run(context) {
const fixture = await loadFixture(context)
if (!fixture) return
const taskId = id(fixture.pdaTaskId)
const taskResponse = await context.request('GET',
`${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
const task = context.apiSuccess(MODULE, '读取人工领取和批量提交后的 PDA 任务', taskResponse, '', OPTIONS)
const afterItems = rows(task?.items).map(stableItem)
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
const beforeStable = fixture.taskItems.map(stableItem)
const allPicked = rows(task?.items).length > 0 && rows(task?.items).every((item) =>
item.itemStatus === 'PICKED'
&& Number(item.pickedPackageCount) === Number(item.plannedPackageCount))
context.assert(MODULE, '领取后任务绑定作业员并以一次批量提交进入待复核', taskResponse,
id(task?.id) === taskId && id(task?.loadingPlanId) === id(fixture.loadingPlanId)
&& id(task?.containerBookingId) === id(fixture.containerBookingId)
&& task?.cabinetNo === fixture.cabinetNo && task?.currentContainerNo === fixture.containerNo
&& task?.taskStatus === 'LOADING' && task?.workflowStatus === 'AWAITING_REVIEW'
&& task?.assignment?.assigneeId && task?.assignment?.assigneeName
&& Number(task?.version) > Number(fixture.beforeTaskVersion)
&& JSON.stringify(afterItems) === JSON.stringify(beforeStable) && allPicked,
`taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, assigneeId=${id(task?.assignment?.assigneeId) || 'missing'}, beforeVersion=${fixture.beforeTaskVersion}, afterVersion=${task?.version ?? 'missing'}`,
OPTIONS)
if (!task || task.workflowStatus !== 'AWAITING_REVIEW') return
const pendingResponse = await context.request('GET',
`${PDA}/tasks/pending?keyword=${encodeURIComponent(fixture.containerNo)}`)
const pending = context.apiSuccess(MODULE, '读取待复核 PDA pending 投影', pendingResponse, '', OPTIONS)
const pendingTask = rows(pending).find((item) => id(item.taskId) === taskId)
context.assert(MODULE, 'pending 与详情一致显示 AWAITING_REVIEW 且无未处理明细', pendingResponse,
Boolean(pendingTask) && pendingTask.workflowStatus === 'AWAITING_REVIEW'
&& Number(pendingTask.itemCount) === rows(task.items).length
&& Number(pendingTask.pendingItemCount) === 0,
`workflowStatus=${pendingTask?.workflowStatus || 'missing'}, itemCount=${pendingTask?.itemCount ?? 'missing'}, pendingItemCount=${pendingTask?.pendingItemCount ?? 'missing'}`,
OPTIONS)
const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`)
const operations = context.apiSuccess(MODULE, '读取领取与原子批量提交审计', operationsResponse, '', OPTIONS)
const claimOperations = rows(operations).filter((item) => item.actionType === 'CLAIM')
const batchOperations = rows(operations).filter((item) => item.actionType === 'BATCH')
context.assert(MODULE, '人工主链只保留一次 CLAIM 和一次 BATCH 且尚无复核完成记录', operationsResponse,
claimOperations.length === 1 && batchOperations.length === 1
&& [...claimOperations, ...batchOperations].every((item) => item.operationId
&& item.operatorId && item.operatorName && item.createdAt)
&& !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)),
`actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}, claimCount=${claimOperations.length}, batchCount=${batchOperations.length}`,
OPTIONS)
const uniqueInbounds = [...new Map(rows(task.items).map((item) => [id(item.inboundId), item])).values()]
for (const item of uniqueInbounds) {
const projectionResponse = await context.request('GET',
`/api/platform/v1/inbounds/${encodeURIComponent(item.inboundId)}/loading/picking?tenantId=${encodeURIComponent(item.tenantId)}`)
const projection = context.apiSuccess(MODULE, `回查 ${item.inboundCode || item.inboundId} 入库级拣货投影`, projectionResponse, '', OPTIONS)
context.assert(MODULE, `${item.inboundCode || item.inboundId} 服务端汇总数量已拣齐`, projectionResponse,
Number(projection?.expectedCount) > 0
&& Number(projection?.expectedCount) === Number(projection?.pickedCount),
`status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`,
OPTIONS)
const evidenceResponse = await context.request('GET',
`/api/platform/v1/inbounds/${encodeURIComponent(item.inboundId)}/loading/evidence?tenantId=${encodeURIComponent(item.tenantId)}`)
const evidence = context.apiSuccess(MODULE, `${item.inboundCode || item.inboundId} 待复核取证入口可读`, evidenceResponse, '', OPTIONS)
context.assert(MODULE, `${item.inboundCode || item.inboundId} AWAITING_REVIEW 可进入取证`, evidenceResponse,
Array.isArray(evidence?.evidences),
`evidenceCount=${evidence?.evidences?.length ?? 'missing'}`, OPTIONS)
}
context.createdResources.manualPdaBatchVerification = {
sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'PDA_AWAITING_REVIEW',
pdaTaskId: taskId, loadingPlanId: id(fixture.loadingPlanId),
containerBookingId: id(fixture.containerBookingId), cabinetNo: fixture.cabinetNo,
containerNo: fixture.containerNo, warehouseId: id(fixture.warehouseId), taskVersion: Number(task.version),
assigneeId: id(task.assignment?.assigneeId), assigneeName: task.assignment?.assigneeName || null,
itemCount: rows(task.items).length,
plannedPackageCount: rows(task.items).reduce((sum, item) => sum + Number(item.plannedPackageCount || 0), 0),
pickedPackageCount: rows(task.items).reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0),
claimOperationId: claimOperations[0]?.operationId || null,
batchOperationId: batchOperations[0]?.operationId || null,
deviceEvidenceRequired: 'HTTP 不记录逐次扫码轨迹;箱码逐一扫描需保留真机人工验收证据。',
nextStage: '上传五类 ACTIVE 装柜证据后,再执行 PDA 最终完成与 LOADED 核验。',
}
}
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs'
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const fixture = readFileSync(new URL('./manual-pda-ready-fixture.mjs', import.meta.url), 'utf8')
const verify = readFileSync(new URL('./manual-pda-ready-verify.mjs', import.meta.url), 'utf8')
test('manual PDA-ready modules are optional and require a source report', () => {
assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-pda-ready-fixture'[^\]]*'manual-pda-ready-verify'/s)
const defaults = parseArguments([]).modules
assert.equal(defaults.includes('manual-pda-ready-fixture'), false)
assert.equal(defaults.includes('manual-pda-ready-verify'), false)
for (const module of ['manual-pda-ready-fixture', 'manual-pda-ready-verify']) {
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
'--password', 'password-value', '--modules', module, '--manual-pda-source-report', 'source.json'])
assert.deepEqual(options.modules, [module])
assert.equal(validateExecutionOptions(options), options)
assert.throws(() => validateExecutionOptions({ ...options, manualPdaSourceReport: '' }),
/manual-pda-source-report/)
}
})
test('PDA-ready preparation is read only and proves all three prerequisites are absent', () => {
assert.match(fixture, /manualPreloadPushVerification/)
assert.match(fixture, /container\?\.containerNo == null/)
assert.match(fixture, /container\?\.oceanBookingId == null/)
assert.match(fixture, /readiness\?\.canConfirm === false/)
assert.match(fixture, /codeOf\(taskResponse\) === 'A0404'/)
assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
})
test('PDA-ready verifier checks release, stable link, confirmation and one unassigned task without writes', () => {
assert.match(verify, /oceanBooking\?\.status === 'RELEASED'/)
assert.match(verify, /positiveId\(allocation\?\.requirementId\)/)
assert.match(verify, /confirmationStatus === 'CONFIRMED'/)
assert.match(verify, /readyForPda === true/)
assert.match(verify, /workflowStatus === 'UNASSIGNED'/)
assert.match(verify, /processStatus === 'WAIT_SPLIT'/)
assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
})
@@ -0,0 +1,89 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const MODULE = 'manual-pda-ready-fixture'
const OPTIONS = { scenario: 'manual-pda-ready-fixture', deploymentBlocker: true }
const SMART = '/api/platform/v1/first-leg/smart-loading'
const FIRST_LEG = '/api/platform/v1/first-leg'
const CONTAINERS = '/api/platform/v1/containers'
const PDA = `${FIRST_LEG}/pda-loading`
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : [] }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function items(value) {
return rows(value).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId),
packageCount: Number(item.packageCount) }))
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
}
async function loadSource(context) {
const report = JSON.parse(await readFile(resolve(context.options.manualPdaSourceReport), 'utf8'))
const source = report?.createdResources?.manualPreloadPushVerification
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
const valid = sourceGateway === context.options.gateway && source?.stage === 'PRELOAD_EDITING'
&& source?.loadingPlanId && source?.containerBookingId && source?.pdaTaskCreated === false
&& Array.isArray(source?.expectedAssignments) && source.expectedAssignments.length > 0
context.assert(MODULE, '读取同环境推送预装核验报告', null, valid,
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(source?.loadingPlanId) || 'missing'}`,
OPTIONS)
return valid ? source : null
}
export async function run(context) {
const source = await loadSource(context)
if (!source) return
const planId = id(source.loadingPlanId)
const containerId = id(source.containerBookingId)
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`)
const plan = context.apiSuccess(MODULE, 'PDA 就绪操作前读取预装计划', planResponse, '', OPTIONS)
const beforeItems = items(plan?.items)
context.assert(MODULE, '操作前计划仍为无号已推送 EDITING', planResponse,
id(plan?.id) === planId && plan?.status === 'EDITING' && plan?.containerNo == null
&& Number(plan?.version) === Number(source.planVersion) && beforeItems.length > 0,
`status=${plan?.status || 'missing'}, expectedVersion=${source.planVersion}, actualVersion=${plan?.version ?? 'missing'}, items=${beforeItems.length}`,
OPTIONS)
if (!plan || beforeItems.length === 0) return
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, 'PDA 就绪操作前读取内部模拟柜', containerResponse, '', OPTIONS)
context.assert(MODULE, '操作前柜次仍无真实柜号和订舱关联', containerResponse,
id(container?.id) === containerId && container?.cabinetNo === source.cabinetNo
&& container?.containerNo == null && container?.oceanBookingId == null
&& Number.isInteger(Number(container?.version)),
`cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${container?.containerNo || 'null'}, oceanBookingId=${id(container?.oceanBookingId) || 'null'}, version=${container?.version ?? 'missing'}`,
OPTIONS)
const detailResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(source.warehouseId)}`)
const detail = context.apiSuccess(MODULE, '操作前读取预装确认门禁', detailResponse, '', OPTIONS)
context.assert(MODULE, '缺少真实柜号与 RELEASED 订舱时不能确认预装', detailResponse,
detail?.summary?.status === 'EDITING' && detail?.summary?.readiness?.canConfirm === false
&& detail?.summary?.readiness?.readyForPda === false
&& detail?.summary?.readiness?.pdaTaskId == null,
`canConfirm=${detail?.summary?.readiness?.canConfirm}, readyForPda=${detail?.summary?.readiness?.readyForPda}, blockers=${rows(detail?.summary?.readiness?.blockers).join(',')}`,
OPTIONS)
const taskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(source.warehouseId)}`)
context.addStep(MODULE, '操作前 PDA 任务仍不存在', taskResponse,
taskResponse.http === 200 && codeOf(taskResponse) === 'A0404',
`HTTP=${taskResponse.http}, code=${codeOf(taskResponse)}`, OPTIONS)
context.createdResources.manualPdaReadyFixture = {
schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(),
stage: 'MANUAL_BOOKING_LINK_CONFIRM_READY', loadingPlanId: planId, containerBookingId: containerId,
cabinetNo: source.cabinetNo, tenantId: id(source.tenantId), warehouseId: id(source.warehouseId),
channelCode: source.channelCode, beforePlanVersion: Number(plan.version),
beforeContainerVersion: Number(container?.version), beforeItems,
expectedAssignments: source.expectedAssignments || [],
instructions: [
'在订舱页面创建与本清单客户、起运仓、渠道和柜型一致的 DRAFT 订舱,并填写承运船司代码 carrierCode 及完整运输资料。',
'确认放舱,使订舱进入 RELEASED;放舱本身不得创建货柜。',
'在原 containerBookingId 上选择该订舱未占用的柜型需求,录入唯一真实 containerNo 完成关联。',
'回到预装详情,确认 canConfirm=true 后执行整份计划确认;不要领取 PDA 任务。',
'记录 bookingNo、containerNo,并立即运行 manual-pda-ready-verify。',
],
}
}
@@ -0,0 +1,122 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const MODULE = 'manual-pda-ready-verify'
const OPTIONS = { scenario: 'manual-pda-ready-readonly-verification', deploymentBlocker: true }
const SMART = '/api/platform/v1/first-leg/smart-loading'
const FIRST_LEG = '/api/platform/v1/first-leg'
const CONTAINERS = '/api/platform/v1/containers'
const PDA = `${FIRST_LEG}/pda-loading`
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : [] }
function positiveId(value) { return /^[1-9][0-9]*$/.test(id(value)) }
function itemSnapshot(value) {
return rows(value).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId),
packageCount: Number(item.packageCount) }))
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
}
async function loadFixture(context) {
const report = JSON.parse(await readFile(resolve(context.options.manualPdaSourceReport), 'utf8'))
const fixture = report?.createdResources?.manualPdaReadyFixture
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1
&& fixture?.stage === 'MANUAL_BOOKING_LINK_CONFIRM_READY' && fixture?.loadingPlanId
&& fixture?.containerBookingId && Array.isArray(fixture?.beforeItems)
&& Array.isArray(fixture?.expectedAssignments) && fixture.expectedAssignments.length > 0
context.assert(MODULE, '读取同环境 PDA 就绪准备报告', null, valid,
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(fixture?.loadingPlanId) || 'missing'}`,
OPTIONS)
return valid ? fixture : null
}
export async function run(context) {
const fixture = await loadFixture(context)
if (!fixture) return
const planId = id(fixture.loadingPlanId)
const containerId = id(fixture.containerBookingId)
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`)
const plan = context.apiSuccess(MODULE, '读取预装确认后的原智能装柜计划', planResponse, '', OPTIONS)
const afterItems = itemSnapshot(plan?.items)
context.assert(MODULE, '订舱关联和预装确认后计划身份、状态及明细稳定', planResponse,
id(plan?.id) === planId && id(plan?.bookingId || plan?.containerBookingId) === containerId
&& plan?.status === 'EDITING' && Number(plan?.version) > Number(fixture.beforePlanVersion)
&& JSON.stringify(afterItems) === JSON.stringify(fixture.beforeItems),
`status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}, items=${afterItems.length}`,
OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '读取关联真实柜号后的稳定柜次', containerResponse, '', OPTIONS)
const oceanBookingId = id(container?.oceanBookingId)
const realContainerNo = String(container?.containerNo || '')
context.assert(MODULE, '真实柜号和订舱原地补全且内部柜次身份不变', containerResponse,
id(container?.id) === containerId && container?.cabinetNo === fixture.cabinetNo
&& realContainerNo.length > 0 && positiveId(oceanBookingId)
&& Number(container?.version) > Number(fixture.beforeContainerVersion)
&& id(container?.warehouseId) === id(fixture.warehouseId) && container?.channelCode === fixture.channelCode,
`containerId=${id(container?.id) || 'missing'}, cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${realContainerNo || 'missing'}, oceanBookingId=${oceanBookingId || 'missing'}`,
OPTIONS)
if (!positiveId(oceanBookingId) || !realContainerNo) return
const bookingResponse = await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(oceanBookingId)}`)
const booking = context.apiSuccess(MODULE, '读取关联后的 RELEASED 订舱与 allocation', bookingResponse, '', OPTIONS)
const allocation = rows(booking?.containers).find((item) => id(item.containerBookingId) === containerId
&& item.containerNo === realContainerNo)
context.assert(MODULE, '订舱已放舱且唯一 allocation 指向原柜次', bookingResponse,
booking?.oceanBooking?.status === 'RELEASED' && positiveId(allocation?.requirementId)
&& positiveId(allocation?.allocationId)
&& rows(booking?.containers).filter((item) => id(item.containerBookingId) === containerId).length === 1,
`status=${booking?.oceanBooking?.status || 'missing'}, bookingNo=${booking?.oceanBooking?.bookingNo || 'missing'}, requirementId=${id(allocation?.requirementId) || 'missing'}, allocationId=${id(allocation?.allocationId) || 'missing'}`,
OPTIONS)
const detailResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
const detail = context.apiSuccess(MODULE, '读取整份预装确认结果', detailResponse, '', OPTIONS)
const taskId = id(detail?.summary?.readiness?.pdaTaskId)
context.assert(MODULE, '预装确认状态为 CONFIRMED 并返回唯一 PDA taskId', detailResponse,
id(detail?.summary?.planId) === planId && detail?.summary?.status === 'EDITING'
&& detail?.summary?.readiness?.confirmationStatus === 'CONFIRMED'
&& detail?.summary?.readiness?.canConfirm === false
&& detail?.summary?.readiness?.readyForPda === true && positiveId(taskId),
`confirmationStatus=${detail?.summary?.readiness?.confirmationStatus || 'missing'}, readyForPda=${detail?.summary?.readiness?.readyForPda}, taskId=${taskId || 'missing'}`,
OPTIONS)
if (!positiveId(taskId)) return
const taskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
const task = context.apiSuccess(MODULE, '读取唯一待领取 PDA 任务', taskResponse, '', OPTIONS)
const plannedPackages = rows(task?.items).reduce((sum, item) => sum + Number(item.plannedPackageCount || 0), 0)
const expectedPackages = rows(fixture.expectedAssignments)
.reduce((sum, item) => sum + Number(item.expectedAllocatedPackageCount || 0), 0)
context.assert(MODULE, 'PDA 任务四元身份、未领取状态和计划箱数正确', taskResponse,
id(task?.id) === taskId && id(task?.loadingPlanId) === planId
&& id(task?.containerBookingId) === containerId && task?.cabinetNo === fixture.cabinetNo
&& task?.currentContainerNo === realContainerNo && task?.taskStatus === 'LOADING'
&& task?.workflowStatus === 'UNASSIGNED' && task?.assignment == null
&& rows(task?.items).length > 0 && plannedPackages === expectedPackages,
`taskId=${id(task?.id) || 'missing'}, taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, plannedPackages=${plannedPackages}, expectedPackages=${expectedPackages}`,
OPTIONS)
for (const assignment of fixture.expectedAssignments || []) {
const inboundResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(assignment.inboundId)}?tenantId=${encodeURIComponent(fixture.tenantId)}`)
const inbound = context.apiSuccess(MODULE, `${assignment.role} claim 前回查入库状态`, inboundResponse, '', OPTIONS)
context.assert(MODULE, `${assignment.role} 在 PDA 领取前仍为 WAIT_SPLIT`, inboundResponse,
id(inbound?.summary?.id) === id(assignment.inboundId)
&& inbound?.summary?.processStatus === 'WAIT_SPLIT',
`inboundId=${id(inbound?.summary?.id) || 'missing'}, status=${inbound?.summary?.processStatus || 'missing'}`,
OPTIONS)
}
context.createdResources.manualPdaReadyVerification = {
sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'PDA_UNASSIGNED',
oceanBookingId, bookingNo: booking?.oceanBooking?.bookingNo || null,
requirementId: id(allocation?.requirementId), allocationId: id(allocation?.allocationId),
loadingPlanId: planId, containerBookingId: containerId, cabinetNo: fixture.cabinetNo,
containerNo: realContainerNo, pdaTaskId: taskId, tenantId: id(fixture.tenantId),
warehouseId: id(fixture.warehouseId), plannedPackageCount: plannedPackages,
nextStage: 'PDA 人工领取任务后进入 LOADING,再执行扫码、批量提交和待复核验收。',
}
}
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs'
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const fixture = readFileSync(new URL('./manual-preload-push-fixture.mjs', import.meta.url), 'utf8')
const verify = readFileSync(new URL('./manual-preload-push-verify.mjs', import.meta.url), 'utf8')
test('manual preload modules are optional and require an explicit source report', () => {
assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-preload-push-fixture'[^\]]*'manual-preload-push-verify'/s)
const defaults = parseArguments([]).modules
assert.equal(defaults.includes('manual-preload-push-fixture'), false)
assert.equal(defaults.includes('manual-preload-push-verify'), false)
for (const module of ['manual-preload-push-fixture', 'manual-preload-push-verify']) {
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
'--password', 'password-value', '--modules', module, '--manual-preload-source-report', 'source.json'])
assert.deepEqual(options.modules, [module])
assert.equal(validateExecutionOptions(options), options)
assert.throws(() => validateExecutionOptions({ ...options, manualPreloadSourceReport: '' }),
/manual-preload-source-report/)
}
})
test('preload preparation is read only and freezes the pre-push plan snapshot', () => {
assert.match(fixture, /manualOrderSplittingVerification/)
assert.match(fixture, /beforePlanVersion/)
assert.match(fixture, /beforeItems/)
assert.match(fixture, /未点击推送前不存在预装 bridge/)
assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
})
test('post-push verifier proves EDITING bridge visibility and PDA A0404 without writes', () => {
assert.match(verify, /Number\(plan\?\.version\) > Number\(fixture\.beforePlanVersion\)/)
assert.match(verify, /allocatedPackageCount/)
assert.match(verify, /preloadStatus === 'PENDING'/)
assert.match(verify, /readiness\?\.canConfirm === false/)
assert.match(verify, /codeOf\(taskResponse\) === 'A0404'/)
assert.match(verify, /container\?\.containerNo == null/)
assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/)
})
@@ -0,0 +1,70 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const MODULE = 'manual-preload-push-fixture'
const OPTIONS = { scenario: 'manual-preload-push-fixture', deploymentBlocker: true }
const SMART = '/api/platform/v1/first-leg/smart-loading'
const CONTAINERS = '/api/platform/v1/containers'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : [] }
function itemSnapshot(items) {
return rows(items).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId),
packageCount: Number(item.packageCount) }))
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
}
async function loadSource(context) {
const report = JSON.parse(await readFile(resolve(context.options.manualPreloadSourceReport), 'utf8'))
const verification = report?.createdResources?.manualOrderSplittingVerification
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
const valid = sourceGateway === context.options.gateway && verification?.loadingPlanId
&& verification?.containerBookingId && Array.isArray(verification?.verifiedAssignments)
context.assert(MODULE, '读取同环境人工分柜核验报告', null, valid,
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(verification?.loadingPlanId) || 'missing'}`,
OPTIONS)
return valid ? verification : null
}
export async function run(context) {
const source = await loadSource(context)
if (!source) return
const planId = id(source.loadingPlanId)
const containerId = id(source.containerBookingId)
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`)
const plan = context.apiSuccess(MODULE, '推送前读取智能装柜计划', planResponse, '', OPTIONS)
const items = itemSnapshot(plan?.items)
context.assert(MODULE, '计划仍处于未推送 EDITING 且分柜明细完整', planResponse,
id(plan?.id) === planId && id(plan?.bookingId || plan?.containerBookingId) === containerId
&& plan?.status === 'EDITING' && Number.isInteger(Number(plan?.version)) && items.length > 0,
`planId=${id(plan?.id) || 'missing'}, status=${plan?.status || 'missing'}, version=${plan?.version ?? 'missing'}, items=${items.length}`,
OPTIONS)
if (!plan || plan.status !== 'EDITING' || items.length === 0) return
const linksResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}/preloads`)
const links = context.apiSuccess(MODULE, '推送前回查尚未建立预装 bridge', linksResponse, '', OPTIONS)
context.assert(MODULE, '未点击推送前不存在预装 bridge', linksResponse,
rows(links).length === 0, `linkCount=${rows(links).length}`, OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '推送前读取内部模拟柜', containerResponse, '', OPTIONS)
context.assert(MODULE, '当前仍是无真实柜号且未关联订舱的内部模拟柜', containerResponse,
id(container?.id) === containerId && container?.containerNo == null && container?.oceanBookingId == null
&& id(container?.warehouseId) === id(source.warehouseId) && container?.channelCode === source.channelCode,
`containerId=${id(container?.id) || 'missing'}, cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${container?.containerNo || 'null'}, oceanBookingId=${id(container?.oceanBookingId) || 'null'}`,
OPTIONS)
context.createdResources.manualPreloadPushFixture = {
schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(),
stage: 'MANUAL_PUSH_PRELOAD_READY', loadingPlanId: planId, containerBookingId: containerId,
cabinetNo: container?.cabinetNo || null, tenantId: id(source.tenantId), warehouseId: id(source.warehouseId),
channelCode: source.channelCode, beforePlanVersion: Number(plan.version), beforeItems: items,
expectedAssignments: source.verifiedAssignments,
instructions: [
'在智能装柜计划页面点击推送预装。',
'本节点保持无真实集装箱号、无订舱关联,不执行计划确认。',
'推送成功后不要继续录入柜号或创建 PDA 任务,立即运行 manual-preload-push-verify。',
],
}
}
@@ -0,0 +1,106 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const MODULE = 'manual-preload-push-verify'
const OPTIONS = { scenario: 'manual-preload-push-readonly-verification', deploymentBlocker: true }
const SMART = '/api/platform/v1/first-leg/smart-loading'
const FIRST_LEG = '/api/platform/v1/first-leg'
const CONTAINERS = '/api/platform/v1/containers'
const PDA = `${FIRST_LEG}/pda-loading`
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : [] }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function itemSnapshot(items) {
return rows(items).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId),
packageCount: Number(item.packageCount) }))
.sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id))
}
async function loadFixture(context) {
const report = JSON.parse(await readFile(resolve(context.options.manualPreloadSourceReport), 'utf8'))
const fixture = report?.createdResources?.manualPreloadPushFixture
const sourceGateway = String(report?.gateway || '').replace(/\/$/, '')
const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1
&& fixture?.stage === 'MANUAL_PUSH_PRELOAD_READY' && fixture?.loadingPlanId
&& fixture?.containerBookingId && Array.isArray(fixture?.beforeItems)
context.assert(MODULE, '读取同环境推送预装准备报告', null, valid,
`sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(fixture?.loadingPlanId) || 'missing'}`,
OPTIONS)
return valid ? fixture : null
}
export async function run(context) {
const fixture = await loadFixture(context)
if (!fixture) return
const planId = id(fixture.loadingPlanId)
const containerId = id(fixture.containerBookingId)
const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`)
const plan = context.apiSuccess(MODULE, '读取人工推送后的智能装柜计划', planResponse, '', OPTIONS)
const afterItems = itemSnapshot(plan?.items)
context.assert(MODULE, '无号推送后计划保持 EDITING、版本递增且明细零漂移', planResponse,
id(plan?.id) === planId && id(plan?.bookingId || plan?.containerBookingId) === containerId
&& plan?.status === 'EDITING' && plan?.containerNo == null
&& Number(plan?.version) > Number(fixture.beforePlanVersion)
&& JSON.stringify(afterItems) === JSON.stringify(fixture.beforeItems),
`status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}, items=${afterItems.length}`,
OPTIONS)
if (!plan || plan.status !== 'EDITING') return
const linksResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}/preloads`)
const links = context.apiSuccess(MODULE, '回查推送原子建立的预装 bridge', linksResponse, '', OPTIONS)
const linkRows = rows(links)
for (const expected of fixture.expectedAssignments || []) {
const matched = linkRows.filter((link) => id(link.inboundId) === id(expected.inboundId))
const allocated = matched.reduce((sum, link) => sum + Number(link.allocatedPackageCount || 0), 0)
context.assert(MODULE, `${expected.role} 预装 bridge 数量与人工分柜一致`, linksResponse,
matched.length >= 1 && allocated === Number(expected.expectedAllocatedPackageCount)
&& matched.every((link) => id(link.loadingPlanId) === planId
&& id(link.containerBookingId) === containerId && link.preloadStatus === 'PENDING'
&& link.loadingPlanStatus === 'EDITING' && Number(link.loadingPlanVersion) === Number(plan.version)),
`inboundId=${id(expected.inboundId)}, expected=${expected.expectedAllocatedPackageCount}, actual=${allocated}, links=${matched.length}`,
OPTIONS)
}
const expectedInboundIds = new Set((fixture.expectedAssignments || []).map((item) => id(item.inboundId)))
const unexpectedLinks = linkRows.filter((link) => !expectedInboundIds.has(id(link.inboundId)))
context.assert(MODULE, '预装 bridge 未混入人工分柜清单外入库单', linksResponse,
unexpectedLinks.length === 0 && linkRows.length >= expectedInboundIds.size,
`linkCount=${linkRows.length}, unexpectedInboundIds=${unexpectedLinks.map((link) => id(link.inboundId)).join(',') || 'none'}`,
OPTIONS)
const detailResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
const detail = context.apiSuccess(MODULE, '读取推送后正式预装清单详情', detailResponse, '', OPTIONS)
context.assert(MODULE, '预装清单保持 EDITING 且无号阶段不能确认或创建 PDA', detailResponse,
id(detail?.summary?.planId) === planId && detail?.summary?.status === 'EDITING'
&& detail?.summary?.readiness?.canConfirm === false
&& detail?.summary?.readiness?.readyForPda === false
&& detail?.summary?.readiness?.pdaTaskId == null,
`planId=${id(detail?.summary?.planId) || 'missing'}, status=${detail?.summary?.status || 'missing'}, canConfirm=${detail?.summary?.readiness?.canConfirm}, readyForPda=${detail?.summary?.readiness?.readyForPda}`,
OPTIONS)
const taskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(fixture.warehouseId)}`)
context.addStep(MODULE, '无真实柜号推送后 PDA 查询仍为 A0404', taskResponse,
taskResponse.http === 200 && codeOf(taskResponse) === 'A0404',
`HTTP=${taskResponse.http}, code=${codeOf(taskResponse)}, requestId=${taskResponse.requestId || 'missing'}, traceId=${taskResponse.traceId || 'missing'}`,
OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '回查推送后内部柜次身份', containerResponse, '', OPTIONS)
context.assert(MODULE, '推送预装不伪造真实柜号或订舱关联', containerResponse,
id(container?.id) === containerId && container?.cabinetNo === fixture.cabinetNo
&& container?.containerNo == null && container?.oceanBookingId == null,
`containerId=${id(container?.id) || 'missing'}, cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${container?.containerNo || 'null'}, oceanBookingId=${id(container?.oceanBookingId) || 'null'}`,
OPTIONS)
context.createdResources.manualPreloadPushVerification = {
sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'PRELOAD_EDITING',
loadingPlanId: planId, containerBookingId: containerId, cabinetNo: fixture.cabinetNo,
tenantId: id(fixture.tenantId), warehouseId: id(fixture.warehouseId), channelCode: fixture.channelCode,
planVersion: Number(plan.version), preloadLinkCount: linkRows.length, pdaTaskCreated: false,
expectedAssignments: fixture.expectedAssignments,
nextStage: '关联 RELEASED 订舱与真实柜号后,人工执行整份预装计划确认。',
}
}
@@ -137,7 +137,7 @@ async function createConfirmedContainer(context, source, label) {
customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户',
channelCode: summary.channelCode, warehouseId: source.warehouseId,
billOfLadingNo: `BL-${value}`.slice(0, 128),
shippingCompanyId: id(agent.id), shippingCompanyName: agent.name,
shippingCompanyId: id(agent.id), shippingCompanyName: agent.name, carrierCode: 'OTHER',
bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX',
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
@@ -39,6 +39,9 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () =>
assert.match(acceptance, /wait-split-count必须是1-100的整数/)
assert.match(acceptance, /批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement/)
assert.match(acceptance, /createAdditionalWaitSplitFixtures/)
assert.match(acceptance, /orderBlockingFailure[\s\S]*step\.deploymentBlocker/)
assert.match(acceptance, /measurementBlockingFailure[\s\S]*step\.deploymentBlocker/)
assert.doesNotMatch(acceptance, /this\.modules\.at\(-1\)\?\.success !== true/)
assert.match(acceptance, /measurement\.processStatus === 'WAIT_SPLIT'/)
assert.match(acceptance, /measurement\.finalStatus === 'APPROVED'/)
assert.match(acceptance, /waitSplitVariant\(sequence\)/)
@@ -56,5 +59,6 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () =>
assert.match(orders, /destinationType === 'PRIVATE'/)
assert.match(orders, /PRIVATE_ADDRESS/)
assert.match(orders, /eligible\.length < 1/)
assert.match(orders, /Client 查询发货客户候选', partnerResponse, '', CONTRACT_OPTIONS/)
assert.doesNotMatch(orders, /customerName: master\.partner\?\.partnerName/)
})
@@ -28,6 +28,7 @@ test('shared DRAFT uses requirements and never registers a real container number
assert.equal(JSON.stringify(draft).includes('containerNo'), false)
assert.equal(draft.serviceTerm, 'CY-CY')
assert.equal(draft.freightTerm, 'NINGBO')
assert.equal(draft.carrierCode, 'OTHER')
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) assert.ok(draft[field])
})
@@ -61,6 +61,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
return {
...writableMaster,
billOfLadingNo: labels.billOfLadingNo || `BL-${suffix}`,
carrierCode: labels.carrierCode || 'OTHER',
shippingCompanyId: labels.shippingCompanyId ?? null,
shippingCompanyName: labels.shippingCompanyId ? labels.shippingCompanyName ?? null : null,
bookingAgentId: labels.bookingAgentId ?? null,
+1 -1
View File
@@ -290,7 +290,7 @@ async function loadMasterData(context, merchantToken) {
const partnerResponse = await request(context, 'GET',
'/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken)
const partners = context.apiSuccess(MODULE, 'Client 查询发货客户候选', partnerResponse)
const partners = context.apiSuccess(MODULE, 'Client 查询发货客户候选', partnerResponse, '', CONTRACT_OPTIONS)
const partner = Array.isArray(partners)
? partners.find((item) => item.status === 'ACTIVE' && item.types?.includes?.('SHIPPER'))
: null
@@ -22,7 +22,7 @@ test('PDA module is injected, token-scoped, traceable, and contains no direct ne
test('PDA module covers the container-number receiving contract and excludes outbound domains', () => {
for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign',
'/current/skus/resolve', '/current/cartons/resolve', '/current/pallets/resolve',
'/current/cartons/resolve', '/current/pallets/resolve',
'/current/receipts/bulk', '/current/receipts/carton', '/current/complete']) assert.ok(source.includes(token), token)
for (const token of ['/inventory/resolve', '/locations/resolve', '/pallets/resolve', '/operations?page=1&size=50']) {
assert.ok(source.includes(token), token)
@@ -41,8 +41,8 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.match(source, /超收被拒绝/)
assert.match(source, /重复箱新键被拒绝/)
assert.match(source, /key\(context, `fba-bulk-\$\{index\}`\)/)
assert.match(source, /packageCount = Number\(resolved\.data\?\.pendingPackages \|\| 0\)/)
assert.match(source, /if \(packageCount <= 0\) continue/)
assert.match(source, /packageCount = Number\(inbound\.pendingPackages \|\| 0\)/)
assert.match(source, /if \(packageCount <= 0\) return true/)
assert.match(source, /key\(context, `overseas-carton-\$\{index\}`\)/)
assert.match(source, /模式硬拒绝/)
assert.match(source, /current返回404正常空态/)
@@ -50,6 +50,8 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.match(source, /warehouse-admin\?tenantId=0&.*capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE/)
assert.match(source, /193000000000200003.*193000000000200004/)
assert.doesNotMatch(source, /requestOverseas\('POST', '\/api\/platform\/v1\/warehouse-admin'/)
assert.doesNotMatch(source, /requestOverseas\('GET', '\/api\/platform\/v1\/warehouse-admin'/)
assert.match(source, /receiving-containers\/current\/pallets\/resolve/)
assert.match(source, /overseasWarehouseId, password/)
assert.match(source, /inbound\?\.overseasWarehouseId/)
assert.doesNotMatch(source, /const warehouseId = id\(inbound\?\.destinationWarehouseId\)/)
@@ -57,11 +59,26 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.match(source, /retained as acceptance evidence/)
assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/)
assert.match(source, /const cartonCandidates =/)
assert.match(source, /candidateExcluded = notFound\(response\)/)
assert.match(source, /if \(candidateExcluded\) continue/)
assert.match(source, /const fixturesConserved = cartonFixtures\.length === expectedPending/)
assert.doesNotMatch(source, /current\/skus\/resolve/)
assert.doesNotMatch(source, /skuCode:/)
assert.match(source, /FBA和OVERSEAS箱码resolve都必须HTTP 200\/code 200并返回当前柜唯一入仓码/)
assert.match(source, /const matches = inbounds\.filter/)
assert.match(source, /!Array\.isArray\(resolved\?\.inboundCode\)/)
assert.match(source, /matches\.length === 1/)
assert.match(source, /destinations\.size === 1/)
assert.doesNotMatch(source, /\.split\([^\n]*['"]#['"]/)
assert.match(source, /`\$\{item\.inboundCode\}#PDA-CONTRACT-\$\{index \+ 1\}`/)
for (const negative of ['无#箱码被服务端拒绝', '空#前缀箱码被服务端拒绝',
'未命中入仓码前缀被服务端拒绝', '其他柜箱码前缀被当前锁定柜拒绝',
'OVERSEAS缺少真实manifest行时不得按标准箱码推导']) {
assert.ok(source.includes(negative), negative)
}
assert.doesNotMatch(source, /FBA标准箱码序号超范围被拒绝/)
assert.match(source, /FBA与OVERSEAS箱码resolve均为纯查询且零状态写入/)
assert.match(source, /receivingSnapshot\(afterResolve\.data\) === beforeResolve/)
assert.match(source, /const fixturesConserved = overseasFixtures\.length === expectedPending/)
assert.match(source, /OVERSEAS实装箱唛与待收箱数守恒/)
assert.match(source, /for \(const \[index, fixture\] of cartonFixtures\.entries\(\)\)/)
assert.match(source, /for \(const \[index, fixture\] of overseasFixtures\.entries\(\)\)/)
assert.doesNotMatch(source, /for \(const \[index, cartonMark\] of cartonMarks\.entries\(\)\)/)
assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/)
assert.match(source, /phase4OverseasPda\.orderId = id\(order\.id\)/)
@@ -70,7 +87,10 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.match(source, /作业历史包含本轮柜号收货动作且分页稳定/)
assert.match(source, /awaitProducerConsumption\(context, allProducers\)/)
assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/)
assert.match(source, /跨区消费生成尾程订单/)
assert.match(source, /跨区广播消费观察/)
assert.match(source, /跨区广播消费观察 \$\{containerNo\}`,[\s\S]*?response,[\s\S]*?true,/)
assert.match(source, /OBSERVATION_OPTIONS = \{ scenario: SCENARIO, deploymentBlocker: false \}/)
assert.doesNotMatch(source, /blockerGap\(context, '跨区消费生产矩阵'/)
assert.match(source, /awaitPickupReadyCandidates\(context, primary\.token, producers, allProducers\)/)
assert.match(source, /missingPickupReady=/)
assert.match(source, /item\?\.shippingReady === false/)
@@ -82,6 +102,40 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.doesNotMatch(source, /awaitProducerCandidates/)
})
test('real-device legacy FBA carton rejection is retained as regression evidence', () => {
const evidence = {
http: 200,
code: 'A0409',
message: 'FBA destination must use bulk count',
requestId: '5e925a3d-8184-41e1-b0d2-f4e1aae86639',
traceId: '62fdf603b65fd7f43ec452bb8a6ca185',
}
assert.deepEqual(evidence, {
http: 200,
code: 'A0409',
message: 'FBA destination must use bulk count',
requestId: '5e925a3d-8184-41e1-b0d2-f4e1aae86639',
traceId: '62fdf603b65fd7f43ec452bb8a6ca185',
})
assert.match(source, /a0409Message\(response, \/\^FBA destination must use bulk count\$\//)
assert.match(source, /a0409Message\(response, \/\^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden\$\//)
})
test('real-device deployed FBA resolve A0404 is retained until server-side inbound-prefix matching lands', () => {
const evidence = {
http: 200,
code: 'A0404',
message: 'Carton mark was not found',
requestId: 'a716e2c2-dc10-4d07-8014-7438afb63090',
traceId: '6ab8fcfcd812056ad6570d3b1652157b',
databaseFact: 'current FBA has no ll_inbound_plan_carton manifest',
}
assert.equal(evidence.code, 'A0404')
assert.equal(evidence.message, 'Carton mark was not found')
assert.match(evidence.databaseFact, /no ll_inbound_plan_carton manifest/)
assert.match(source, /a0404Message\(response, \/\^Carton mark was not found\$\//)
})
test('PDA receiving lifecycle is ordered from candidate claim through photos, mode-specific receipts, pallet and complete', () => {
const orderedTokens = [
"'查询当前客户待收柜号'", "'选择柜号并建立WAIT_SIGN会话'",
@@ -1,6 +1,7 @@
const MODULE = 'phase4-overseas-pda'
const SCENARIO = 'phase4-overseas-pda-container-receiving'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const OBSERVATION_OPTIONS = { scenario: SCENARIO, deploymentBlocker: false }
const PHOTO_ROLES = ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']
const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
@@ -11,13 +12,27 @@ function success(response) { return response?.http === 200 && String(response?.b
function code(response) { return String(response?.body?.code ?? '') }
function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) }
function conflict(response) { return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) && traced(response) }
function conflictMessage(response, pattern) { return conflict(response) && pattern.test(String(response?.body?.message || '')) }
function a0409Message(response, pattern) { return response?.http === 200 && code(response) === 'A0409' && traced(response) && pattern.test(String(response?.body?.message || '')) }
function a0404Message(response, pattern) { return response?.http === 200 && code(response) === 'A0404' && traced(response) && pattern.test(String(response?.body?.message || '')) }
function cartonResolveRejected(response) { return response?.http === 200 && ['A0400', 'A0404', 'A0409'].includes(code(response)) && traced(response) }
function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && traced(response) }
function forbidden(response) { return [401, 403].includes(response?.http) || (response?.http === 200 && ['A0401', 'A0403'].includes(code(response))) }
function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` }
function key(context, action, suffix = '') { return `${context.marker}-${context.pdaContainerSuffix || ''}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) }
function markerSuffix(context) { return `${context.marker}${context.pdaContainerSuffix || ''}`.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() }
function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] }
function receivingSnapshot(data) {
return JSON.stringify({
containerNo: data?.containerNo, status: data?.status, version: data?.version,
totalPackages: data?.totalPackages, receivedPackages: data?.receivedPackages,
pendingPackages: data?.pendingPackages,
inbounds: (Array.isArray(data?.inbounds) ? data.inbounds : []).map((item) => ({
inboundCode: item?.inboundCode, destinationType: item?.destinationType,
totalPackages: item?.totalPackages, receivedPackages: item?.receivedPackages,
pendingPackages: item?.pendingPackages,
})).sort((left, right) => `${left.inboundCode}:${left.destinationType}`.localeCompare(`${right.inboundCode}:${right.destinationType}`)),
})
}
function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) {
context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true })
@@ -159,7 +174,7 @@ async function createPallet(context, inbound, index) {
return longId(palletId) ? { warehouseId, locationId, locationCode, palletId, palletCode, retained: true } : null
}
async function verifyWarehouseQueries(context, token, inboundCode, pallet) {
async function verifyWarehouseQueries(context, token, inboundCode, pallet, destinationType) {
const inventory = await pda(context, token, 'POST', '完成入库后按入仓码现场查询库存',
'/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode })
context.assert(MODULE, '现场库存查询只返回Token绑定仓且包含库存位置', inventory.response,
@@ -168,6 +183,11 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet) {
&& inventory.data.items.every((item) => id(item?.palletId) === pallet.palletId
&& id(item?.locationId) === pallet.locationId && Number(item?.availablePackages) > 0),
`items=${inventory.data?.items?.length ?? 'missing'}; ${trace(inventory.response)}`, OPTIONS)
const inventoryItems = Array.isArray(inventory.data?.items) ? inventory.data.items : []
context.assert(MODULE, `${destinationType}实收形成服务端SKU库存账`, inventory.response,
success(inventory.response) && inventoryItems.length > 0
&& inventoryItems.every((item) => nonBlank(item?.skuCode) && Number(item?.availablePackages) > 0),
`skuCodes=${[...new Set(inventoryItems.map((item) => item?.skuCode).filter(nonBlank))].join('|') || 'missing'}, availablePackages=${inventoryItems.reduce((total, item) => total + Number(item?.availablePackages || 0), 0)}; ${trace(inventory.response)}`, OPTIONS)
const location = await pda(context, token, 'POST', '完成入库后扫码查询库位',
'/api/pda/v1/last-leg/locations/resolve', { code: pallet.locationCode })
@@ -243,9 +263,9 @@ async function awaitProducerConsumption(context, producers) {
}
for (const containerNo of expected) {
const response = lastResponses.get(containerNo) || null
context.addStep(MODULE, `跨区消费生成尾程订单 ${containerNo}`, response,
consumed.has(containerNo),
`containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OPTIONS)
context.addStep(MODULE, `跨区广播消费观察 ${containerNo}`, response,
true,
`containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OBSERVATION_OPTIONS)
}
return expected.every((containerNo) => consumed.has(containerNo))
}
@@ -286,110 +306,117 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer
return ready
}
function skuFixtures(context, producer) {
const frozenLines = Array.isArray(producer?.skuLines)
? producer.skuLines
.map((line) => ({ skuCode: id(line?.skuCode), packageCount: Number(line?.packageCount) }))
.filter((line) => nonBlank(line.skuCode) && Number.isInteger(line.packageCount) && line.packageCount > 0)
: []
if (frozenLines.length) return frozenLines
const frozen = Array.isArray(producer?.skuCodes)
? producer.skuCodes.map(id).filter(nonBlank)
: []
if (frozen.length) return [...new Set(frozen)].map((skuCode) => ({ skuCode, packageCount: 1 }))
const boxes = context.createdResources?.orders?.boxTypes
const orchestration = context.createdResources?.orchestrationFixtures || {}
const markers = [context.marker, orchestration.phase3Loading?.sourceMarker,
orchestration.phase3LoadingIncremental?.sourceMarker].filter(nonBlank)
return Array.isArray(boxes)
? [...new Set(markers.flatMap((marker) => boxes.map((boxType) => `SKU-${boxType}-${marker}`.slice(0, 64))))]
.map((skuCode) => ({ skuCode, packageCount: 1 }))
: []
}
async function receiveFba(context, token, inbound, pallet, producer) {
const skus = skuFixtures(context, producer)
if (!skus.length) {
blockerGap(context, `FBA入仓码${inbound.inboundCode} SKU`, '本轮订单 fixture 未交接冻结 SKU,无法安全猜测实收 SKU')
return false
}
let accepted = 0
for (const [index, sku] of skus.entries()) {
const { skuCode } = sku
const resolvedResponse = await context.requestOverseas('POST',
'/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token)
const resolvable = success(resolvedResponse) || notFound(resolvedResponse)
context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS)
const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null }
if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue
const packageCount = Number(resolved.data?.pendingPackages || 0)
if (packageCount <= 0) continue
const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode,
packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) }
const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
if (!success(first.response)) return false
accepted += Number(first.data?.acceptedPackages || 0)
const replay = await pda(context, token, 'POST', `FBA SKU ${skuCode}同键同载荷重放`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
context.assert(MODULE, `FBA SKU ${skuCode}幂等结果一致`, replay.response,
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
await expected(context, token, 'POST', `FBA SKU ${skuCode}同键异载荷冲突`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk',
{ ...body, packageCount: packageCount + 1 }, conflict)
await expected(context, token, 'POST', `FBA SKU ${skuCode}超收被拒绝`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk',
{ ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, `fba-over-${index}`) }, conflict)
}
if (accepted <= 0) {
blockerGap(context, `FBA入仓码${inbound.inboundCode}实收`, `候选 SKU 均未唯一解析到当前入仓码;skuCount=${skus.length}`)
return false
}
return true
}
async function receiveOverseas(context, token, inbounds, pallets, producerCartonMarks = []) {
const cartonCandidates = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks])
.map(id).filter(nonBlank))]
async function resolveCartonFixtures(context, token, inbounds, producerCartonMarks = []) {
const manifestCartons = (Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks])
.map(id).filter(nonBlank)
const fbaCartons = inbounds.filter((item) => item.destinationType === 'FBA')
.map((item, index) => `${item.inboundCode}#PDA-CONTRACT-${index + 1}`)
const overseasCartons = inbounds.some((item) => item.destinationType === 'OVERSEAS') ? manifestCartons : []
const cartonCandidates = [...new Set([...fbaCartons, ...overseasCartons])]
if (!cartonCandidates.length) {
blockerGap(context, 'OVERSEAS逐箱链', '缺少本轮PDA任务冻结箱唛,逐箱实收不能使用伪造箱号')
return false
blockerGap(context, '统一箱码解析链', 'FBA缺少当前柜入仓码或OVERSEAS缺少真实manifest箱码')
return null
}
const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0)
const cartonFixtures = []
let lastResponse = null
for (const [index, cartonMark] of cartonCandidates.entries()) {
const response = await context.requestOverseas('POST',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, {}, token)
lastResponse = response
const candidateExcluded = notFound(response)
context.addStep(MODULE, `筛选OVERSEAS实装箱唛候选${index + 1}`, response,
success(response) || candidateExcluded,
`${candidateExcluded ? 'candidateSelected=false' : 'candidateSelected=true'}; ${trace(response)}`, OPTIONS)
if (candidateExcluded) continue
if (!success(response)) {
blockerGap(context, `OVERSEAS箱唛候选${cartonMark}预检`,
'候选箱唛既非本柜冻结事实,也不是未被最终实装选中的A0404;禁止继续收货',
'fixture-mismatch', response)
return false
}
const resolved = response.body?.data
const inbound = inbounds.find((item) => item.inboundCode === resolved?.inboundCode)
const pallet = inbound ? pallets.get(inbound.inboundCode) : null
if (!inbound || !pallet) {
blockerGap(context, `OVERSEAS箱唛${cartonMark}归属`,
`冻结箱唛未解析到带真实托盘的本柜入仓码;inboundCode=${resolved?.inboundCode || 'missing'}`,
const matches = inbounds.filter((item) => item.inboundCode === resolved?.inboundCode)
const destinations = new Set(matches.map((item) => item?.destinationType).filter(nonBlank))
const unique = success(response) && nonBlank(resolved?.inboundCode) && !Array.isArray(resolved?.inboundCode)
&& matches.length === 1 && destinations.size === 1
&& ['FBA', 'OVERSEAS'].includes(resolved?.destinationType)
&& destinations.has(resolved?.destinationType)
context.addStep(MODULE, `统一解析${resolved?.destinationType || 'UNKNOWN'}箱码${index + 1}并返回唯一入仓码`, response,
unique, `cartonMark=${cartonMark}, inboundCode=${resolved?.inboundCode || 'missing'}, matches=${matches.length}; ${trace(response)}`, OPTIONS)
if (!unique) {
blockerGap(context, `箱码${cartonMark}唯一归属`,
`FBA和OVERSEAS箱码resolve都必须HTTP 200/code 200并返回当前柜唯一入仓码;inboundCode=${resolved?.inboundCode || 'missing'}, matches=${matches.length}`,
'fixture-mismatch', response)
return false
return null
}
cartonFixtures.push({ cartonMark, pallet })
cartonFixtures.push({ cartonMark, inbound: matches[0], resolved })
}
const fixturesConserved = cartonFixtures.length === expectedPending
context.assert(MODULE, 'OVERSEAS实装箱唛与待收箱数守恒', lastResponse, fixturesConserved,
`resolvedFixtures=${cartonFixtures.length}, pendingPackages=${expectedPending}; ${trace(lastResponse)}`, OPTIONS)
if (!fixturesConserved) {
blockerGap(context, 'OVERSEAS逐箱冻结事实',
`候选池未完整覆盖本柜真实冻结箱唛;resolvedFixtures=${cartonFixtures.length}, pendingPackages=${expectedPending}`,
'fixture-mismatch', lastResponse)
return cartonFixtures
}
async function probeCartonResolveNegatives(context, token, current) {
const fba = current.inbounds.find((item) => item.destinationType === 'FBA')
await expected(context, token, 'POST', '无#箱码被服务端拒绝',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: fba?.inboundCode || `NO-HASH-${markerSuffix(context)}` }, cartonResolveRejected)
await expected(context, token, 'POST', '空#前缀箱码被服务端拒绝',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: `#${markerSuffix(context)}` }, cartonResolveRejected)
await expected(context, token, 'POST', '未命中入仓码前缀被服务端拒绝',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected)
const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || []
const crossContainerMark = allProducers
.filter((item) => id(item?.containerNo) !== id(current.containerNo))
.flatMap((item) => Array.isArray(item?.cartonMarks) ? item.cartonMarks : [item?.cartonMark])
.map(id).find(nonBlank)
if (crossContainerMark) {
await expected(context, token, 'POST', '其他柜箱码前缀被当前锁定柜拒绝',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: crossContainerMark }, cartonResolveRejected)
} else {
blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证')
}
const overseas = current.inbounds.find((item) => item.destinationType === 'OVERSEAS')
if (overseas) {
const total = Math.max(1, Number(overseas.totalPackages || 1))
await expected(context, token, 'POST', 'OVERSEAS缺少真实manifest行时不得按标准箱码推导',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: `${overseas.inboundCode}#${total + 1}-1` },
(response) => a0404Message(response, /^Carton mark was not found$/))
}
}
async function receiveFba(context, token, inbound, pallet, cartonFixtures, index) {
const resolvedCartons = cartonFixtures.filter((item) => item.inbound.inboundCode === inbound.inboundCode
&& item.resolved.destinationType === 'FBA')
if (!resolvedCartons.length) {
blockerGap(context, `FBA入仓码${inbound.inboundCode}箱码解析`, '没有冻结箱码以200/code 200唯一解析到当前FBA入仓码')
return false
}
for (const [index, fixture] of cartonFixtures.entries()) {
const packageCount = Number(inbound.pendingPackages || 0)
if (packageCount <= 0) return true
const body = { inboundCode: inbound.inboundCode, palletCode: pallet.palletCode,
packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) }
const first = await pda(context, token, 'POST', `FBA入仓码${inbound.inboundCode}按箱数实收`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
if (!success(first.response)) return false
const replay = await pda(context, token, 'POST', `FBA入仓码${inbound.inboundCode}同键同载荷重放`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
context.assert(MODULE, `FBA入仓码${inbound.inboundCode}幂等结果一致`, replay.response,
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
await expected(context, token, 'POST', `FBA入仓码${inbound.inboundCode}同键异载荷冲突`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { ...body, packageCount: packageCount + 1 }, conflict)
await expected(context, token, 'POST', `FBA入仓码${inbound.inboundCode}超收被拒绝`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk',
{ ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, `fba-over-${index}`) }, conflict)
return true
}
async function receiveOverseas(context, token, inbounds, pallets, cartonFixtures) {
const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0)
const overseasFixtures = cartonFixtures.filter((item) => item.resolved.destinationType === 'OVERSEAS'
&& inbounds.some((inbound) => inbound.inboundCode === item.inbound.inboundCode))
.map((item) => ({ cartonMark: item.cartonMark, pallet: pallets.get(item.inbound.inboundCode) }))
const fixturesConserved = overseasFixtures.length === expectedPending && overseasFixtures.every((item) => item.pallet)
context.assert(MODULE, 'OVERSEAS实装箱唛与待收箱数守恒', null, fixturesConserved,
`resolvedFixtures=${overseasFixtures.length}, pendingPackages=${expectedPending}`, OPTIONS)
if (!fixturesConserved) {
blockerGap(context, 'OVERSEAS逐箱冻结事实',
`统一解析结果未完整覆盖本柜真实冻结箱码;resolvedFixtures=${overseasFixtures.length}, pendingPackages=${expectedPending}`,
'fixture-mismatch')
return false
}
for (const [index, fixture] of overseasFixtures.entries()) {
const { cartonMark, pallet } = fixture
const body = { cartonMark, palletCode: pallet.palletCode,
idempotencyKey: key(context, `overseas-carton-${index}`) }
@@ -480,6 +507,18 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
{ ...signBody, photos: [...photos].reverse() }, conflict)
const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current')
if (!success(current.response)) return
const beforeResolve = receivingSnapshot(current.data)
await probeCartonResolveNegatives(context, token, current.data)
const producerCartonMarks = preferredProducer?.cartonMarks?.length
? preferredProducer.cartonMarks : [preferredProducer?.cartonMark]
const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks)
if (!cartonFixtures) return
const afterResolve = await pda(context, token, 'GET', '箱码解析正负向后回查当前柜零写入',
'/api/pda/v1/last-leg/receiving-containers/current')
context.assert(MODULE, 'FBA与OVERSEAS箱码resolve均为纯查询且零状态写入', afterResolve.response,
success(afterResolve.response) && receivingSnapshot(afterResolve.data) === beforeResolve,
`before=${beforeResolve}, after=${receivingSnapshot(afterResolve.data)}; ${trace(afterResolve.response)}`, OPTIONS)
if (!success(afterResolve.response) || receivingSnapshot(afterResolve.data) !== beforeResolve) return
const pallets = new Map()
for (const [index, inbound] of current.data.inbounds.entries()) {
const pallet = await createPallet(context, inbound, index)
@@ -499,25 +538,30 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS')
if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) {
const firstFba = fbaInbounds[0]
const fbaCarton = cartonFixtures.find((item) => item.resolved.destinationType === 'FBA'
&& item.inbound.inboundCode === firstFba.inboundCode)
if (!fbaCarton) {
blockerGap(context, `FBA入仓码${firstFba.inboundCode}箱码反证`, '没有冻结FBA箱码可用于验证carton收货精确A0409')
return
}
await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', {
cartonMark: context.createdResources?.inboundMeasurement?.boxCode || 'MODE-REJECT-FBA',
cartonMark: fbaCarton?.cartonMark,
palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'),
}, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i))
for (const inbound of fbaInbounds) {
await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), preferredProducer)
}, (response) => a0409Message(response, /^FBA destination must use bulk count$/))
for (const [index, inbound] of fbaInbounds.entries()) {
await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), cartonFixtures, index)
}
} else if (preferredProducer?.destinationType !== 'OVERSEAS') {
blockerGap(context, 'FBA SKU按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码')
blockerGap(context, 'FBA按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码')
}
if (overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) {
const firstOverseas = overseasInbounds[0]
const pallet = pallets.get(firstOverseas.inboundCode)
await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', {
inboundCode: firstOverseas.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode,
inboundCode: firstOverseas.inboundCode, palletCode: pallet.palletCode,
packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'),
}, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i))
await receiveOverseas(context, token, overseasInbounds, pallets,
preferredProducer?.cartonMarks?.length ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark])
}, (response) => a0409Message(response, /^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden$/))
await receiveOverseas(context, token, overseasInbounds, pallets, cartonFixtures)
} else if (preferredProducer?.destinationType !== 'FBA') {
blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码')
}
@@ -539,7 +583,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId)
for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) {
const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode,
pallets.get(inbound.inboundCode))
pallets.get(inbound.inboundCode), inbound.destinationType)
if (warehouseFacts && context.context?.phase4OverseasPdaOperations) {
context.context.phase4OverseasPdaOperations.completedFixtures.push({
containerNo, order, ...warehouseFacts,
@@ -594,11 +638,7 @@ export async function run(context) {
const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]]
.filter((item) => nonBlank(item?.containerNo))
const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers
if (allProducers.length && !(await awaitProducerConsumption(context, allProducers))) {
blockerGap(context, '跨区消费生产矩阵',
'三期已发布的四柜尚未全部由海外消费者生成尾程订单,禁止把PICKUP_READY缺口与事件消费混为一谈')
return
}
if (allProducers.length) await awaitProducerConsumption(context, allProducers)
if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) {
blockerGap(context, '实际处理柜PICKUP_READY门禁',
'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜')
@@ -7,7 +7,7 @@
- 用于开发、联调或经明确授权的验收环境准备人工操作数据。
- 生成结果固定为入库单 `processStatus=WAIT_SPLIT`、测量审核 `APPROVED`。
- 单次支持 `1-100` 条;更大批量分多次执行并分别保存报告。
- 不负责后续分柜、装柜、订舱、国内出仓、海外入仓或 PDA 收货。
- 基础批量模式不自动执行后续分柜、装柜、订舱、国内出仓、海外入仓或 PDA 收货;需要人工分柜验收时使用本文后半部分的准备与只读核验入口。
- 禁止用于生产环境。对任何共享环境执行前,必须在当前对话明确获得该环境的写数据授权。
## 固定项与随机项
@@ -121,6 +121,286 @@ node scripts/admin-test-env-acceptance.mjs `
报告生成前会扫描密码、Token、文件二进制、base64 和 OCR 原文等敏感内容。报告仍属于本地验收证据,不应提交到源码仓库。
## 人工分柜验收
### 1. 生成分柜准备清单
人工分柜模块默认生成 6 票同客户、同起运仓、同渠道的 `WAIT_SPLIT` 数据,并在报告的 `createdResources.manualOrderSplittingFixture` 中给出:
- 一票 `PARTIAL`:只分配部分箱数,固定保留 1 箱;
- 两票 `FULL_1/FULL_2`:整票加入同一个柜,验证多订单拼柜;
- 一票 `UNASSIGNED_CONTROL`:不得加入计划,用于核对未分配对照和候选余量;
- 全部订单、入库单、入仓码、箱数、客户、仓库和渠道信息。
复用既有客户时执行:
```powershell
node scripts/admin-test-env-acceptance.mjs `
--modules manual-order-splitting-fixture `
--merchant-username $env:MERCHANT_TEST_USERNAME `
--merchant-password $env:MERCHANT_TEST_PASSWORD `
--origin-warehouse-name '<固定国内仓名称>' `
--channel-name '<固定渠道名称>' `
--channel-code '<固定渠道编码>' `
--order-profile-mode fixed `
--wait-split-count 6 `
--output '<人工分柜准备报告绝对路径>.json'
```
`manual-order-splitting-fixture` 会自动展开 `orders → inbound-measurement`。本入口通过正式 HTTP 接口写入测试数据,仍需当前对话明确授权目标环境。
### 2. Admin 人工操作
根据准备报告执行:
1. 打开智能装柜页面,创建与清单一致的起运仓、渠道模拟柜。
2. 创建智能装柜计划。
3. 严格按 `assignments[].expectedAllocatedPackageCount` 分配三票入仓码。
4. 不要选择 `unassignedControl`。
5. 保存计划但暂不推送预装,记录页面或接口返回的 `loadingPlanId`。
此阶段只是“计划分柜”。父子订单和最终实装数量要到 PDA 实装结算后形成,不能在本节点提前宣称已经完成订单拆分。
### 3. 人工操作后只读核验
```powershell
node scripts/admin-test-env-acceptance.mjs `
--modules manual-order-splitting-verify `
--manual-split-fixture-report '<人工分柜准备报告绝对路径>.json' `
--manual-split-plan-id '<页面记录的 loadingPlanId>' `
--output '<人工分柜核验报告绝对路径>.json'
```
核验模块只发送 GET 请求,检查:
- 计划与柜次 ID 有效,计划仍处于可继续验收的 `EDITING/LOADING`;
- 柜次仓库、渠道与准备清单一致;
- `PARTIAL/FULL_1/FULL_2` 的计划箱数精确匹配;
- 未混入清单外入库单,`UNASSIGNED_CONTROL` 未被误选;
- 候选回查不出现异渠道或异仓入库单;
- 每票分配后的候选剩余箱数满足数量守恒。
核验报告会写入 `createdResources.manualOrderSplittingVerification`,记录来源 marker、计划、柜次和已核验分配,不保存凭据。
## 人工推送预装验收
完成上一节人工分柜核验后,继续验证“点击推送预装,但尚无真实柜号、未关联订舱”的节点。
### 1. 生成推送前操作清单
使用上一节生成的“人工分柜核验报告”:
```powershell
node scripts/admin-test-env-acceptance.mjs `
--modules manual-preload-push-fixture `
--manual-preload-source-report '<人工分柜核验报告绝对路径>.json' `
--output '<推送预装前准备报告绝对路径>.json'
```
该模块只发送 GET 请求,并确认:
- 智能装柜计划仍为 `EDITING`;
- 人工分柜明细存在且版本有效;
- 尚未建立预装 bridge;
- 柜次仍为无真实 `containerNo`、无 `oceanBookingId` 的内部模拟柜。
成功后,报告中的 `createdResources.manualPreloadPushFixture` 会保存推送前计划版本、明细快照和操作说明。
### 2. Admin 人工推送
在同一智能装柜计划上点击“推送预装”。本节点只做这一项操作:
- 不录入真实集装箱号;
- 不创建或关联订舱;
- 不确认预装计划;
- 不领取或创建 PDA 任务。
推送成功后立即执行下一步只读核验。
### 3. 推送后只读核验
```powershell
node scripts/admin-test-env-acceptance.mjs `
--modules manual-preload-push-verify `
--manual-preload-source-report '<推送预装前准备报告绝对路径>.json' `
--output '<推送预装核验报告绝对路径>.json'
```
核验内容包括:
- 计划仍为 `EDITING`,版本相对推送前递增,分柜明细没有漂移;
- 每票入库单生成 `PENDING` 预装 bridge,箱数与人工分柜一致;
- 正式预装清单详情可见;
- 无真实柜号阶段 `canConfirm=false`、`readyForPda=false`;
- PDA 任务查询返回 `A0404`;
- 推送没有伪造真实柜号、订舱关联或改变稳定柜次身份。
通过后报告写入 `createdResources.manualPreloadPushVerification`,下一节点再处理订舱放舱、真实柜号关联和整份预装计划确认。
## 人工订舱关联、预装确认与 PDA 待领取验收
### 1. 冻结操作前基线
以上一节“推送预装核验报告”为来源:
```powershell
node scripts/admin-test-env-acceptance.mjs `
--modules manual-pda-ready-fixture `
--manual-pda-source-report '<推送预装核验报告绝对路径>.json' `
--output '<PDA就绪前准备报告绝对路径>.json'
```
准备模块只发送 GET 请求,确认:
- 已推送计划仍为 `EDITING`,版本和分柜明细没有变化;
- 原柜次仍无真实柜号、无订舱关联;
- 当前 `canConfirm=false`、`readyForPda=false`;
- PDA 查询仍返回 `A0404`。
报告的 `createdResources.manualPdaReadyFixture` 保存柜次、计划、版本和明细基线。
### 2. Admin 人工操作
严格按以下顺序操作:
1. 创建与准备清单客户、起运仓、渠道、柜型一致的海运订舱 DRAFT,并补齐承运船司代码 `carrierCode`、提单号、船名、航次、ETD、ETA、参与方和柜型需求。
2. 点击放舱,使订舱进入 `RELEASED`。此时不应自动生成真实柜次或 PDA 任务。
3. 在原内部柜次上选择该订舱未占用的柜型需求,录入唯一真实 `containerNo` 完成关联;不得新建替代柜次。
4. 回到预装详情,确认门禁变为 `canConfirm=true`,再执行整份预装计划确认。
5. 确认成功后不要领取 PDA 任务,记录订舱号和真实柜号并立即执行核验。
### 3. 只读核验 PDA 待领取状态
```powershell
node scripts/admin-test-env-acceptance.mjs `
--modules manual-pda-ready-verify `
--manual-pda-source-report '<PDA就绪前准备报告绝对路径>.json' `
--output '<PDA待领取核验报告绝对路径>.json'
```
核验内容包括:
- 订舱为 `RELEASED`,且 allocation 唯一指向原 `containerBookingId`;
- 真实柜号在原柜次上补全,`cabinetNo/loadingPlanId/items` 不漂移;
- 整份预装为 `CONFIRMED`、`readyForPda=true`,并返回唯一 `pdaTaskId`;
- PDA task 四元身份正确,`taskStatus=LOADING`、`workflowStatus=UNASSIGNED`;
- PDA 计划箱数与人工分柜总箱数一致;
- 尚未领取任务时各来源入库单仍保持 `WAIT_SPLIT`。
通过后报告写入 `createdResources.manualPdaReadyVerification`。下一节点进入 PDA 人工领取、扫码、批量提交和待复核验收。
## PDA 人工领取、扫码和批量提交验收
### 1. 冻结 PDA 作业前基线
以上一节“PDA 待领取核验报告”为来源:
```powershell
node scripts/admin-test-env-acceptance.mjs `
--modules manual-pda-batch-fixture `
--manual-pda-batch-source-report '<PDA待领取核验报告绝对路径>.json' `
--output '<PDA作业前准备报告绝对路径>.json'
```
准备模块只发送 GET 请求,确认并记录:
- 任务为 `UNASSIGNED`,尚未绑定作业员;
- PDA 待办中只有同一稳定 `taskId`;
- 每条任务明细包含稳定 `boxCodes`,数量足以覆盖计划箱数;
- 库存快照的任务版本和四元身份一致;
- 操作历史尚无 `CLAIM/BATCH/REVIEW/COMPLETE_LOADING`。
报告的 `createdResources.manualPdaBatchFixture.taskItems[].scanBoxCodes` 提供本轮应扫描箱码,`boxCodes` 保留服务端完整下发集合;库存 `snapshotToken` 不写入报告,PDA 提交前必须实时重新获取。
### 2. PDA 真机人工操作
1. 使用目标仓库作业员进入 PDA 待办并领取任务,确认页面进入作业中。
2. 按准备报告对应任务明细,在真机逐箱扫描稳定箱码;同一箱码不能重复扫描。
3. 所有明细均按计划箱数完成,本节点不使用 `PARTIAL` 或 `SKIP`。
4. 点击提交时由 PDA 重新获取库存快照,并只发送一次原子 batch。
5. 成功后停在 `AWAITING_REVIEW`,不要上传五类证据,不要复核或执行最终完成。
服务端 batch 只接收明细与数量,不保存逐次扫码轨迹。因此自动报告只能证明稳定箱码已下发和服务端数量已收敛,真机逐箱扫码过程必须保留人工证据。
### 3. 批量提交后只读核验
```powershell
node scripts/admin-test-env-acceptance.mjs `
--modules manual-pda-batch-verify `
--manual-pda-batch-source-report '<PDA作业前准备报告绝对路径>.json' `
--output '<PDA待复核核验报告绝对路径>.json'
```
核验内容包括:
- 任务已绑定真实作业员,版本递增;
- 所有明细均为 `PICKED`,已拣箱数等于计划箱数;
- 任务保持 `taskStatus=LOADING`,工作流进入 `AWAITING_REVIEW`;
- pending 与 detail 均显示零条未处理明细;
- 操作历史恰有一次 `CLAIM`、一次 `BATCH`,没有 `REVIEW/COMPLETE_LOADING`;
- 各入库单服务端汇总 `expectedCount=pickedCount`;
- 待复核取证 GET 已开放。
通过后报告写入 `createdResources.manualPdaBatchVerification`,并明确标记逐次扫码仍需真机证据。下一节点处理五类装柜证据和 PDA 最终完成。
## 五类装柜证据、PDA 最终完成与 LOADED 验收
### 1. 冻结最终完成前基线
以上一节“PDA 待复核核验报告”为来源:
```powershell
node scripts/admin-test-env-acceptance.mjs `
--modules manual-loading-complete-fixture `
--manual-loading-complete-source-report '<PDA待复核核验报告绝对路径>.json' `
--output '<装柜最终完成前准备报告绝对路径>.json'
```
准备模块只发送 GET 请求,确认:
- PDA 任务为 `AWAITING_REVIEW`,所有明细已拣齐;
- 装柜计划仍为 `LOADING/IN_PROGRESS`;
- 任务审计只有一次 `CLAIM` 和一次 `BATCH`;
- 当前证据归属入库单没有 ACTIVE 装柜证据;
- 真实柜号和 `RELEASED` 订舱门禁仍有效。
报告的 `createdResources.manualLoadingCompleteFixture` 会固定证据归属、五类证据要求和最终完成前版本。
### 2. PDA 真机人工操作
在同一 PDA 任务内现场完成:
1. 拍摄空仓照 `PHOTO_STEP_1`。
2. 拍摄半仓照 `PHOTO_STEP_2`。
3. 拍摄满仓照 `PHOTO_STEP_3`。
4. 拍摄锁仓照 `PHOTO_STEP_4`。
5. 拍摄带锁码照 `SEAL`,并填写非空锁码。
6. 确认五类均为 ACTIVE 后,只点击一次最终完成。
不要调用旧入库单级兼容完成入口;当前唯一正式动作是 PDA task 的最终完成。成功后不要继续修改柜号、计划或证据。
### 3. LOADED 终态只读核验
```powershell
node scripts/admin-test-env-acceptance.mjs `
--modules manual-loading-complete-verify `
--manual-loading-complete-source-report '<装柜最终完成前准备报告绝对路径>.json' `
--output '<LOADED终态核验报告绝对路径>.json'
```
核验内容包括:
- 五类 ACTIVE 证据各一份,SEAL 锁码非空且每张图片可鉴权下载;
- 任务 `taskStatus/workflowStatus` 均为 `COMPLETED`;
- 操作历史恰有一次 `COMPLETE_LOADING`,并保留原 CLAIM/BATCH;
- 原装柜计划进入 `COMPLETED`;
- 所有来源入库单进入 `LOADED`,状态历史包含 `LOADING → LOADED`;
- 对应统一订单详情投影为 `LOADED`;
- 柜次实装分片唯一,实装箱数等于 PDA 已拣总箱数;
- 货柜总览聚合 COMPLETED PDA 和五类 ACTIVE 证据。
通过后报告写入 `createdResources.manualLoadingCompleteVerification`。至此,本指南覆盖的国内人工验收链已经从订单造数连续到装柜 `LOADED`;后续报关、国内出仓和海外接柜另按对应节点扩展。
## 失败、重跑与批量拆分
- 批次遇到第一条失败即停止,已经成功创建的订单不会自动删除或回滚。
@@ -148,7 +428,17 @@ node scripts/admin-test-env-acceptance.mjs `
```powershell
node --check scripts/admin-test-env-acceptance.mjs
node --check scripts/admin-test-env-modules/orders.mjs
node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs
node --check scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs
node --check scripts/admin-test-env-modules/manual-order-splitting-verify.mjs
node --check scripts/admin-test-env-modules/manual-preload-push-fixture.mjs
node --check scripts/admin-test-env-modules/manual-preload-push-verify.mjs
node --check scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs
node --check scripts/admin-test-env-modules/manual-pda-ready-verify.mjs
node --check scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs
node --check scripts/admin-test-env-modules/manual-pda-batch-verify.mjs
node --check scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs
node --check scripts/admin-test-env-modules/manual-loading-complete-verify.mjs
node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs
git diff --check
```
+73 -7
View File
@@ -189,7 +189,7 @@ Assert-Contains $overseasCompose 'LASTLEG_SECURITY_SERVICE_URL' 'Lastleg 安全
Assert-NotContains $serviceJenkinsfile "'overseas-security-service'" '独立海外安全服务构建入口'
# 高变化语义必须在最近指引与服务契约中保持一致。
Assert-Contains $adminAgents '海外出库(范围外现存代码)' 'Admin 四期范围边界'
Assert-Contains $adminAgents '海外出库、派送、异常与回访(范围外现存代码)' 'Admin 四期范围边界'
Assert-Contains $pdaAgents 'VITE_H5_API_BASE_URL' '国内 PDA H5 运行配置'
Assert-Contains $overseasPdaAgents 'tests/app-icons.test.mjs' '海外 PDA 图标合同'
Assert-Contains $backendAgents 'RestClientTraceInterceptor.java' '后端同步 Trace 入口'
@@ -205,11 +205,22 @@ Assert-Contains $adminOceanBookingApi '/link-candidates' 'Admin 订舱关联候
Assert-Contains $adminOceanBookingView "label: '订舱柜量'" 'Admin 订舱柜量文案'
Assert-Contains $adminOceanBookingViewTest 'without detail N+1' 'Admin 订舱列表无详情扇出合同'
# 已注册但超出当前四期边界的海外出库只能作为范围漂移事实存在,不得进入根正式业务路由。
# 已注册但超出当前四期边界的海外出库及派生运营页只能作为范围漂移事实存在,不得进入根正式业务路由。
$adminOverseasRoutes = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/routes.ts')
Assert-Contains $adminOverseasRoutes "path: 'overseas-warehouse/outbounds'" 'Admin 海外出库现存路由'
Assert-Contains $adminAgents '海外出库(范围外现存代码)' 'Admin 海外出库范围警示'
Assert-NotContains $rootAgents 'OutboundsView.vue' '根正式四期业务路由'
foreach ($outOfScopeOverseasRoute in @(
'overseas-warehouse/outbounds',
'overseas-warehouse/delivery-orders',
'overseas-warehouse/exception-orders',
'overseas-warehouse/customer-visits'
)) {
Assert-Contains $adminOverseasRoutes "path: '$outOfScopeOverseasRoute'" "Admin 范围外现存路由 $outOfScopeOverseasRoute"
}
Assert-Contains $adminAgents '海外出库、派送、异常与回访(范围外现存代码)' 'Admin 海外范围漂移警示'
foreach ($outOfScopeRootEntry in @(
'OutboundsView.vue', 'DeliveryOrdersView.vue', 'ExceptionOrdersView.vue', 'CustomerVisitsView.vue'
)) {
Assert-NotContains $rootAgents $outOfScopeRootEntry '根正式四期业务路由'
}
# Admin 仅对静态资源过期做一次受控恢复,不能吞掉普通路由错误或覆盖未保存编辑。
$navigationRecovery = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/core/routing/navigationRecovery.ts')
@@ -226,6 +237,34 @@ Assert-Contains $domesticPdaVite 'API_PROXY_TARGET' '国内 PDA H5 代理上游'
Assert-Contains $domesticPdaSession 'session.apiBase = PLATFORM_API_ROOT' '国内 PDA 当前构建 API'
Assert-NotContains $domesticPdaSession 'apiBase: session.apiBase' '国内 PDA 会话持久化'
# 国内和海外 PDA 使用相同协议但完全独立的 APK/WGT 更新通道。
$domesticPdaUpdater = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/utils/app-update.js')
$domesticPdaApp = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/App.vue')
$overseasPdaUpdater = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/utils/app-update.js')
$overseasPdaApp = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/App.vue')
foreach ($pdaUpdate in @(
@{ Source = $domesticPdaUpdater; Scope = '国内 PDA 在线更新' },
@{ Source = $overseasPdaUpdater; Scope = '海外 PDA 在线更新' }
)) {
Assert-Contains $pdaUpdate.Source 'VITE_PDA_UPDATE_URL' $pdaUpdate.Scope
Assert-Contains $pdaUpdate.Source "['apk', 'wgt'].includes(packageType)" $pdaUpdate.Scope
Assert-Contains $pdaUpdate.Source '更新包必须使用 HTTPS 地址' $pdaUpdate.Scope
Assert-Contains $pdaUpdate.Source 'plus.runtime.install' $pdaUpdate.Scope
}
Assert-Contains $domesticPdaApp '#ifdef APP-PLUS' '国内 PDA App 更新平台边界'
Assert-Contains $overseasPdaApp '#ifdef APP-PLUS' '海外 PDA App 更新平台边界'
Assert-Contains $pdaAgents '不得复用海外 PDA' '国内 PDA 独立更新通道'
Assert-Contains $overseasPdaAgents '不得与国内 PDA 混用' '海外 PDA 独立更新通道'
# 人工分柜到 LOADED 只允许使用已登记的可选 fixture/verify 链,不进入默认全量。
$adminAcceptanceEntry = Read-RequiredText (Join-Path $backendRoot 'scripts/admin-test-env-acceptance.mjs')
$adminAcceptanceModuleIndex = Read-RequiredText (Join-Path $backendRoot 'scripts/admin-test-env-modules/README.md')
$scriptsIndex = Read-RequiredText (Join-Path $backendRoot 'scripts/README.md')
Assert-Contains $adminAcceptanceEntry "const OPTIONAL_MODULES = ['manual-order-splitting-fixture'" '人工验收可选模块注册'
Assert-Contains $adminAcceptanceModuleIndex '人工分柜通过后可继续执行 `manual-preload-push-fixture.mjs`' '人工分柜到预装链路'
Assert-Contains $adminAcceptanceModuleIndex 'PDA 待复核通过后,`manual-loading-complete-fixture.mjs`' '人工 PDA 到 LOADED 链路'
Assert-Contains $scriptsIndex 'manual-*-fixture/verify' '人工分段验收根索引'
# 同步 RestClient Trace 必须注册为公共自动配置,不能只有孤立拦截器源码。
$commonWebAutoConfigurations = Read-RequiredText (Join-Path $backendRoot 'common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports')
Assert-Contains $commonWebAutoConfigurations 'com.baiyue.common.web.RestClientTracePropagationAutoConfiguration' 'RestClient Trace 自动配置注册'
@@ -290,6 +329,13 @@ $criticalPaths = @(
'apps/admin-web/src/core/routing/navigationRecovery.test.ts',
'apps/admin-web/src/modules/overseas-warehouse/api/outbounds.ts',
'apps/admin-web/src/modules/overseas-warehouse/pages/OutboundsView.vue',
'apps/admin-web/src/modules/overseas-warehouse/api/deliveryOrders.ts',
'apps/admin-web/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue',
'apps/admin-web/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.vue',
'apps/admin-web/src/modules/overseas-warehouse/api/exceptionOrders.ts',
'apps/admin-web/src/modules/overseas-warehouse/pages/ExceptionOrdersView.vue',
'apps/admin-web/src/modules/overseas-warehouse/api/customerVisits.ts',
'apps/admin-web/src/modules/overseas-warehouse/pages/CustomerVisitsView.vue',
'apps/admin-web/src/modules/iam/pages/InternalUsersView.vue',
'apps/admin-web/src/modules/iam/api/platformIam.ts',
'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts',
@@ -303,6 +349,8 @@ $criticalPaths = @(
'apps/pda-uniapp/config/runtime.js',
'apps/pda-uniapp/.env.example',
'apps/pda-uniapp/vite.config.js',
'apps/pda-uniapp/utils/app-update.js',
'apps/pda-uniapp/tests/app-update.test.mjs',
'apps/pda-uniapp/utils/evidence-upload-queue.js',
'apps/pda-overseas-uniapp/package.json',
'apps/pda-overseas-uniapp/src/pages.json',
@@ -318,6 +366,12 @@ $criticalPaths = @(
'apps/pda-overseas-uniapp/src/utils/scan-broadcast.js',
'apps/pda-overseas-uniapp/src/manifest.json',
'apps/pda-overseas-uniapp/tests/app-icons.test.mjs',
'apps/pda-overseas-uniapp/src/utils/app-update.js',
'apps/pda-overseas-uniapp/tests/app-update.test.mjs',
'apps/pda-overseas-uniapp/src/i18n/index.js',
'apps/pda-overseas-uniapp/src/i18n/locales/zh-CN.js',
'apps/pda-overseas-uniapp/src/i18n/locales/en.js',
'apps/pda-overseas-uniapp/src/i18n/locales/es.js',
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java',
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java',
@@ -421,7 +475,19 @@ $criticalPaths = @(
'baiyue-firstleg/scripts/deploy-built-service-rollback.test.mjs',
'baiyue-firstleg/scripts/java-quality-summary.mjs',
'baiyue-firstleg/scripts/java-quality-summary.test.mjs',
'docs/四期/船节点标准化契约.md'
'baiyue-firstleg/scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs',
'baiyue-firstleg/scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs',
'baiyue-firstleg/scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs',
'baiyue-firstleg/scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs',
'baiyue-firstleg/scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs',
'docs/四期/船节点标准化契约.md',
'docs/一期/一期头程订单闭环设计.md',
'docs/二期/二期需求优化版-v2-测量计费入库主流程.md',
'docs/墨家设备测量上传接口说明-V3.md',
'docs/三期/三期需求确认表.md',
'docs/统一订单主状态与操作契约.md',
'docs/财务管理/财务管理产品确认表.md',
'docs/财务管理/Firstleg-Finance跨服务契约.md'
)
$criticalPaths | ForEach-Object { Assert-Path $_ }
@@ -491,5 +557,5 @@ if ($failures.Count -gt 0) {
exit 1
}
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
exit 0
@@ -37,6 +37,30 @@ test('coordinator jobs present their own deployment environment as the default c
assert.match(coordinatorPipeline, /downstreamJobPrefixForJob\(env\.JOB_BASE_NAME\)/)
})
test('AUTO keeps its own baseline and retries only previously failed services', () => {
assert.match(coordinatorPipeline, /def parseAutoBuildState\(/)
assert.match(coordinatorPipeline, /def previousAutoBuildState\(/)
assert.match(coordinatorPipeline, /previousAutoBuildState\(currentBuild\.previousBuild, serviceModules\)/)
assert.match(coordinatorPipeline, /selected\.addAll\(retryServices\)/)
assert.match(coordinatorPipeline, /AUTO \$\{env\.IMAGE_TAG\} \| PENDING/)
assert.match(coordinatorPipeline, /AUTO \$\{env\.IMAGE_TAG\} \| RESULTS/)
assert.doesNotMatch(coordinatorPipeline, /commonChanged \? serviceModules/)
})
test('AUTO maps shared modules to their real consumers instead of rebuilding every service', () => {
const securityImpact = coordinatorPipeline.slice(
coordinatorPipeline.indexOf("'common-security/': ["),
coordinatorPipeline.indexOf("'common-openapi/': [")
)
assert.match(coordinatorPipeline, /def fullRebuildPaths = \[/)
assert.match(coordinatorPipeline, /'common-security\/': \[/)
assert.match(coordinatorPipeline, /'common-event-contract\/': \[/)
assert.match(coordinatorPipeline, /commonPathServices\.each \{ prefix, impactedServices ->/)
assert.match(coordinatorPipeline, /selected\.addAll\(impactedServices\)/)
assert.match(securityImpact, /'auth'.*'finance'.*'file'/s)
assert.doesNotMatch(securityImpact, /gateway|print|integration-service|event-bridge-service/)
})
test('production deployments cannot skip tests', () => {
assert.match(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS/)
assert.match(coordinatorPipeline, /error\('生产环境禁止跳过测试'\)/)
@@ -102,7 +102,8 @@ CALL add_column_if_missing('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL
CALL add_column_if_missing('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id');
CALL add_column_if_missing('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code');
CALL add_column_if_missing('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name');
CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type');
CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL COMMENT ''船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填'' AFTER partner_type');
CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER carrier_code');
CALL add_column_if_missing('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name');
CALL add_column_if_missing('wh_partner', 'contact_email', 'VARCHAR(128) NULL');
CALL add_column_if_missing('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email');
@@ -118,6 +119,8 @@ CALL add_column_if_missing('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL');
CALL add_column_if_missing('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_partner', 'version', 'INT NOT NULL DEFAULT 0');
UPDATE wh_partner SET carrier_code = 'OTHER'
WHERE id = 191000000000000001 AND partner_type = 'CARRIER' AND carrier_code IS NULL;
-- 该表在旧库中不存在时先由 warehouse-express-company.sql 建表;此段补齐早期部署缺列。
CALL add_column_if_missing('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id');
@@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS wh_partner (
data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围',
partner_code VARCHAR(50) NOT NULL COMMENT '平台内唯一往来单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '往来单位名称',
partner_type VARCHAR(20) NOT NULL COMMENT '往来单位类型:CARRIER、SHIPPER或CONSIGNEE',
carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填',
contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(32) NOT NULL COMMENT '联系电话',
contact_email VARCHAR(128) NULL COMMENT '联系邮箱', country_code CHAR(2) NOT NULL COMMENT '国家二字码',
province VARCHAR(64) NULL COMMENT '省或州', city VARCHAR(64) NOT NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县',
@@ -16,7 +17,8 @@ CREATE TABLE IF NOT EXISTS wh_partner (
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted),
KEY idx_wh_partner_tenant_status(tenant_id,status,deleted),
CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))
CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')),
CONSTRAINT chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))
) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据';
CREATE TABLE IF NOT EXISTS wh_partner_type (
partner_id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0',
@@ -25,11 +25,12 @@ class WarehouseSchemaContractTest {
void shouldHandleCase02() throws IOException {
String sql = read("warehouse-partner.sql");
assertColumns(sql, "wh_partner", "data_scope_id", "partner_code", "partner_name", "partner_type", "contact_name",
"contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line",
"carrier_code", "contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line",
"postal_code", "status", "remark");
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_partner_type")
.contains("idx_wh_partner_type_scope")
.contains("chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))")
.contains("chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))")
.contains("chk_wh_partner_type_value CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))")
.doesNotContain("CUSTOMS_BROKER", "SUPPLIER或BOTH");
assertBaseColumns(sql);
@@ -100,6 +101,8 @@ class WarehouseSchemaContractTest {
.contains("CALL add_column_if_missing('wh_warehouse_address'")
.contains("CALL add_column_if_missing('wh_service_capability'")
.contains("CALL add_column_if_missing('wh_partner'")
.contains("CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL")
.contains("UPDATE wh_partner SET carrier_code = 'OTHER'")
.contains("CALL add_column_if_missing('wh_express_company'")
.contains("DROP PROCEDURE IF EXISTS add_column_if_missing")
.doesNotContain("ADD COLUMN IF NOT EXISTS")
@@ -150,7 +153,13 @@ class WarehouseSchemaContractTest {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
assertThat(tableDefinition(unified, "wh_partner"))
.contains("chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))")
.contains("carrier_code VARCHAR(16) NULL")
.contains("chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))")
.doesNotContain("CUSTOMS_BROKER", "SUPPLIER或BOTH");
assertThat(unified)
.contains("CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL")
.contains("UPDATE wh_partner SET carrier_code = 'OTHER'")
.contains("queue_column_comment('baiyue_warehouse', 'wh_partner', 'carrier_code'");
}
private static String read(String file) throws IOException {