Merge branch 'dev' into dev-lyhfive
# Conflicts: # deploy/sql/头程数据库统一增量-MySQL8.sql # deploy/sql/海外区域统一增量-MySQL8.sql # lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java # lastleg-service/src/main/resources/db/lastleg.sql # lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
This commit is contained in:
+2
-1
@@ -31,7 +31,8 @@ node scripts/create-pending-review-orders.mjs
|
||||
| Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` |
|
||||
| PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` |
|
||||
| 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` |
|
||||
| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、关键入口与排除目录 |
|
||||
| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、四期范围漂移、订舱候选与柜量语义、海外 Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、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`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 |
|
||||
|
||||
Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境:
|
||||
|
||||
@@ -5,9 +5,11 @@ import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-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 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 MODULE_DEPENDENCIES = new Map([
|
||||
['suppliers', ['merchant-qualification-fixture']],
|
||||
['supplemental', ['merchant-qualification-fixture']],
|
||||
['orders', ['merchant-qualification-fixture']],
|
||||
['inbound-measurement', ['orders']],
|
||||
['phase3-container-loading', ['inbound-measurement']],
|
||||
@@ -20,9 +22,10 @@ const MODULE_DEPENDENCIES = new Map([
|
||||
['truck-appointments', ['merchant-qualification-fixture']],
|
||||
['service-center-flow', ['ocean-bookings', 'truck-appointments']],
|
||||
['phase3-loading-finalize', ['phase3-loading', 'phase3-evidence-gate']],
|
||||
['phase3-container-documents', ['phase3-loading-finalize']],
|
||||
['phase3-customs', ['phase3-loading-finalize']],
|
||||
['phase3-port-schedules', ['ocean-bookings']],
|
||||
['phase3-outbound-dispatch', ['phase3-customs', 'phase3-port-schedules', 'service-center-flow']],
|
||||
['phase3-outbound-dispatch', ['phase3-container-documents', 'phase3-customs', 'phase3-port-schedules', 'service-center-flow']],
|
||||
['phase3-outbound-aggregate', ['phase3-outbound-dispatch']],
|
||||
['phase3-dispatch-gate', ['phase3-outbound-dispatch']],
|
||||
['phase3-waiting-for-loading', ['phase3-outbound-dispatch']],
|
||||
@@ -232,11 +235,20 @@ function usage() {
|
||||
--username <name> 必填,管理员账号
|
||||
--password <value> 必填,管理员密码
|
||||
--modules <names> 逗号分隔,默认 ${DEFAULT_MODULES.join(',')}
|
||||
--customer-name <name> 指定本轮隔离客户名称,默认自动生成
|
||||
--merchant-username <name> 复用既有已审核客户账号,不再创建客户
|
||||
--merchant-password <value> 复用客户账号密码,不写入报告
|
||||
--origin-warehouse-name <name> 固定国内起运仓名称
|
||||
--channel-name <name> 固定物流渠道名称
|
||||
--channel-code <code> 固定物流渠道编码
|
||||
--order-profile-mode <mode> 订单档案模式:fixed 或 randomized,默认fixed
|
||||
--wait-split-count <n> 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1
|
||||
--output <path> JSON 报告路径,默认 reports/
|
||||
--help 显示帮助
|
||||
|
||||
也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
|
||||
ADMIN_TEST_WEIGHT_IMAGE_PATH`)
|
||||
ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME,
|
||||
MERCHANT_TEST_PASSWORD, ADMIN_WAIT_SPLIT_COUNT`)
|
||||
}
|
||||
|
||||
export function parseArguments(argv) {
|
||||
@@ -255,12 +267,27 @@ export function parseArguments(argv) {
|
||||
values.set(name, next); index += 1
|
||||
}
|
||||
}
|
||||
const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || ''
|
||||
const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean))
|
||||
if (merchantUsername && merchantPassword) {
|
||||
const fixtureIndex = modules.indexOf('merchant-qualification-fixture')
|
||||
if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1)
|
||||
}
|
||||
return {
|
||||
gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''),
|
||||
overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || '').replace(/\/$/, ''),
|
||||
username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '',
|
||||
password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '',
|
||||
modules: normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)),
|
||||
modules,
|
||||
customerName: values.get('customer-name') || process.env.ADMIN_TEST_CUSTOMER_NAME || '',
|
||||
merchantUsername,
|
||||
merchantPassword,
|
||||
originWarehouseName: values.get('origin-warehouse-name') || '',
|
||||
channelName: values.get('channel-name') || '',
|
||||
channelCode: values.get('channel-code') || '',
|
||||
orderProfileMode: values.get('order-profile-mode') || 'fixed',
|
||||
waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT || 1),
|
||||
output: values.get('output') || '',
|
||||
}
|
||||
}
|
||||
@@ -273,6 +300,32 @@ export function validateExecutionOptions(options) {
|
||||
&& (!options.overseasGateway || options.overseasGateway.trim() === '')) {
|
||||
throw new Error('四期海外模块缺少必填执行参数: overseas-gateway')
|
||||
}
|
||||
if (!Number.isInteger(options.waitSplitCount) || options.waitSplitCount < 1 || options.waitSplitCount > 100) {
|
||||
throw new Error('wait-split-count必须是1-100的整数')
|
||||
}
|
||||
if (options.customerName && options.customerName.trim().length > 128) {
|
||||
throw new Error('customer-name不能超过128个字符')
|
||||
}
|
||||
if (Boolean(options.merchantUsername) !== Boolean(options.merchantPassword)) {
|
||||
throw new Error('merchant-username和merchant-password必须同时提供')
|
||||
}
|
||||
for (const [name, value] of [['origin-warehouse-name', options.originWarehouseName],
|
||||
['channel-name', options.channelName], ['channel-code', options.channelCode]]) {
|
||||
if (value && value.trim().length > 128) throw new Error(`${name}不能超过128个字符`)
|
||||
}
|
||||
if (!['fixed', 'randomized'].includes(options.orderProfileMode)) {
|
||||
throw new Error('order-profile-mode必须为fixed或randomized')
|
||||
}
|
||||
if (options.orderProfileMode === 'randomized'
|
||||
&& (!options.originWarehouseName || !options.channelName || !options.channelCode)) {
|
||||
throw new Error('randomized订单档案必须同时固定起运仓、渠道名称和渠道编码')
|
||||
}
|
||||
if (options.waitSplitCount > 1) {
|
||||
const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement'])
|
||||
if (options.modules.some((module) => !fixtureModules.has(module))) {
|
||||
throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement')
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
@@ -350,7 +403,9 @@ export class AcceptanceRun {
|
||||
this.coverageGaps = []
|
||||
this.context = {}
|
||||
this.token = ''
|
||||
this.refreshToken = ''
|
||||
this.overseasToken = ''
|
||||
this.overseasExpiresAt = 0
|
||||
this.fatal = null
|
||||
}
|
||||
|
||||
@@ -553,6 +608,148 @@ export class AcceptanceRun {
|
||||
this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal })
|
||||
}
|
||||
|
||||
captureWaitSplitFixture(sequence) {
|
||||
const order = this.createdResources.orders || {}
|
||||
const measurement = this.createdResources.inboundMeasurement || {}
|
||||
const ready = Boolean(order.orderId && measurement.inboundId
|
||||
&& String(order.tenantId) === String(measurement.tenantId)
|
||||
&& measurement.processStatus === 'WAIT_SPLIT' && measurement.finalStatus === 'APPROVED')
|
||||
this.assert('wait-split-fixtures', `固定客户第 ${sequence} 单停在待分柜`, null, ready,
|
||||
`tenantId=${measurement.tenantId || 'missing'}, orderId=${order.orderId || 'missing'}, inboundId=${measurement.inboundId || 'missing'}, processStatus=${measurement.processStatus || 'missing'}, reviewStatus=${measurement.finalStatus || 'missing'}`,
|
||||
{ scenario: 'fixed-customer-wait-split', deploymentBlocker: true })
|
||||
if (!ready) return false
|
||||
this.createdResources.waitSplitFixtures ??= []
|
||||
this.createdResources.waitSplitFixtures.push({
|
||||
sequence, tenantId: String(measurement.tenantId), orderId: String(order.orderId),
|
||||
orderNo: order.orderNo || null, inboundId: String(measurement.inboundId),
|
||||
inboundCode: order.inboundCode || null, processStatus: measurement.processStatus,
|
||||
reviewStatus: measurement.finalStatus, skuCount: order.skuLines?.length || null,
|
||||
totalPackageCount: (order.skuLines || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0),
|
||||
palletCount: measurement.palletCount || null,
|
||||
actualGrossWeightKg: measurement.actualGrossWeightKg || null,
|
||||
actualNetWeightKg: measurement.actualNetWeightKg || null,
|
||||
measurementProfile: this.context.waitSplitVariant?.measurementProfile || null,
|
||||
receivingWarehouseId: order.receivingWarehouseId || null,
|
||||
receivingWarehouseName: order.receivingWarehouseName || null,
|
||||
destinationWarehouseId: order.destinationWarehouseId || null,
|
||||
destinationWarehouseCode: order.destinationWarehouseCode || null,
|
||||
destinationWarehouseName: order.destinationWarehouseName || null,
|
||||
channelCode: order.channelCode || null,
|
||||
channelName: order.channelName || null,
|
||||
pricingTaxMode: order.pricingTaxMode || null,
|
||||
destinationType: order.destinationType || null,
|
||||
fulfillmentType: order.fulfillmentType || null,
|
||||
pickupMethod: order.pickupMethod || null,
|
||||
taxMode: order.taxMode || null,
|
||||
containerMode: order.containerMode || null,
|
||||
customsInspection: order.customsInspection ?? null,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
waitSplitVariant(sequence) {
|
||||
const dimensionProfiles = [
|
||||
{ code: 'SMALL', first: { length: 29.1, width: 19.1, height: 18.1 }, replacement: { length: 29.5, width: 19.5, height: 18.5 }, outer: { length: 34, width: 24, height: 24 } },
|
||||
{ code: 'MEDIUM', first: { length: 39.1, width: 29.1, height: 28.1 }, replacement: { length: 39.5, width: 29.5, height: 28.5 }, outer: { length: 44, width: 34, height: 34 } },
|
||||
{ code: 'LARGE', first: { length: 49.1, width: 39.1, height: 38.1 }, replacement: { length: 49.5, width: 39.5, height: 38.5 }, outer: { length: 54, width: 44, height: 44 } },
|
||||
{ code: 'EXTRA_LARGE', first: { length: 59.1, width: 49.1, height: 48.1 }, replacement: { length: 59.5, width: 49.5, height: 48.5 }, outer: { length: 64, width: 54, height: 54 } },
|
||||
]
|
||||
const skuCount = 1 + ((sequence - 1) % 4)
|
||||
const packageCountPerSku = 1 + (Math.floor((sequence - 1) / 4) % 5)
|
||||
const palletCount = 1 + ((sequence - 1) % 3)
|
||||
const grossWeightPerPalletKg = 45 + ((sequence - 1) % 6) * 15
|
||||
return {
|
||||
sequence, skuCount, packageCountPerSku, palletCount, grossWeightPerPalletKg,
|
||||
measurementProfile: dimensionProfiles[(sequence - 1) % dimensionProfiles.length],
|
||||
}
|
||||
}
|
||||
|
||||
randomizedOrderProfile(sequence) {
|
||||
const combinations = [
|
||||
['FBA', 'STANDARD'],
|
||||
['OVERSEAS', 'STANDARD'],
|
||||
['PRIVATE', 'STANDARD'],
|
||||
['FBA', 'ONE_CLICK_DROPSHIP'],
|
||||
['OVERSEAS', 'ONE_CLICK_DROPSHIP'],
|
||||
['PRIVATE', 'ONE_CLICK_DROPSHIP'],
|
||||
]
|
||||
const [destinationType, fulfillmentType] = combinations[(sequence * 5 + 1) % combinations.length]
|
||||
const pickupMethods = ['PICKUP', 'SELF_EXPRESS', 'SELF_TRUCK']
|
||||
return {
|
||||
destinationType,
|
||||
fulfillmentType,
|
||||
pickupMethod: pickupMethods[(sequence * 7 + 2) % pickupMethods.length],
|
||||
taxMode: sequence % 2 === 0 ? 'TAX_REFUND' : 'BUY_DECLARATION',
|
||||
containerMode: sequence % 2 === 0 ? 'FULL_CONTAINER' : 'LCL',
|
||||
customsInspection: sequence % 4 === 0,
|
||||
}
|
||||
}
|
||||
|
||||
applyWaitSplitVariant(sequence) {
|
||||
const variant = this.waitSplitVariant(sequence)
|
||||
this.context.waitSplitVariant = variant
|
||||
this.context.phase3LoadingIncrementalDetailCount = variant.skuCount
|
||||
this.context.phase3LoadingIncrementalPackageCount = variant.packageCountPerSku
|
||||
const profile = this.options.orderProfileMode === 'randomized'
|
||||
? this.randomizedOrderProfile(sequence)
|
||||
: { destinationType: 'FBA', fulfillmentType: 'STANDARD', pickupMethod: 'PICKUP',
|
||||
taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false }
|
||||
this.context.orderProfile = profile
|
||||
this.context.orderDestinationType = profile.destinationType
|
||||
this.context.orderFulfillmentType = profile.fulfillmentType
|
||||
this.context.waitSplitRoute = {
|
||||
originWarehouseName: this.options.originWarehouseName || '',
|
||||
channelName: this.options.channelName || '',
|
||||
channelCode: this.options.channelCode || '',
|
||||
destinationIndex: (sequence * 17 + 11),
|
||||
taxModeIndex: sequence - 1,
|
||||
}
|
||||
}
|
||||
|
||||
async createAdditionalWaitSplitFixtures() {
|
||||
const total = this.options.waitSplitCount
|
||||
if (total <= 1) return
|
||||
const baseMarker = this.marker
|
||||
const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url))
|
||||
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
|
||||
try {
|
||||
for (let sequence = 2; sequence <= total; sequence += 1) {
|
||||
this.marker = `${baseMarker}-WS${String(sequence).padStart(2, '0')}`
|
||||
this.applyWaitSplitVariant(sequence)
|
||||
await this.executeModule('orders', () => orders.run(this))
|
||||
if (this.modules.at(-1)?.success !== true) break
|
||||
await this.executeModule('inbound-measurement', () => measurement.run(this))
|
||||
if (this.modules.at(-1)?.success !== true || !this.captureWaitSplitFixture(sequence)) break
|
||||
}
|
||||
} finally {
|
||||
this.marker = baseMarker
|
||||
}
|
||||
}
|
||||
|
||||
async refreshSession(reason) {
|
||||
const response = await this.request('POST', '/api/auth/refresh',
|
||||
{ refreshToken: this.refreshToken }, '')
|
||||
const next = this.apiSuccess('authentication', '国内 refresh 签发新双区域会话', response,
|
||||
`reason=${reason}`, { scenario: 'dual-token-refresh', deploymentBlocker: true })
|
||||
const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken)
|
||||
&& Boolean(next?.overseasAccessToken) && Number(next?.overseasExpiresIn) > 0
|
||||
this.assert('authentication', '刷新响应完整后原子替换国内与海外Token', response, valid,
|
||||
`accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}, overseasTokenPresent=${Boolean(next?.overseasAccessToken)}, overseasExpiresIn=${next?.overseasExpiresIn ?? 'missing'}`,
|
||||
{ scenario: 'dual-token-refresh', deploymentBlocker: true })
|
||||
if (!valid) return false
|
||||
const replacement = {
|
||||
token: next.accessToken,
|
||||
refreshToken: next.refreshToken,
|
||||
overseasToken: next.overseasAccessToken,
|
||||
overseasExpiresAt: Date.now() + Number(next.overseasExpiresIn) * 1000,
|
||||
}
|
||||
this.token = replacement.token
|
||||
this.refreshToken = replacement.refreshToken
|
||||
this.overseasToken = replacement.overseasToken
|
||||
this.overseasExpiresAt = replacement.overseasExpiresAt
|
||||
return true
|
||||
}
|
||||
|
||||
async findAvailablePhone(module = 'customers') {
|
||||
const seed = BigInt(Date.now() % 10_000_000_000)
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
@@ -987,11 +1184,15 @@ export class AcceptanceRun {
|
||||
}
|
||||
}
|
||||
|
||||
async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD') {
|
||||
async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD', pickupRequired = false) {
|
||||
const matrixMarker = this.marker
|
||||
const variantMarker = `${matrixMarker}-P4-${label}`
|
||||
const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker']
|
||||
const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking']
|
||||
const createdKeys = [
|
||||
'phase3Loading', 'phase3LoadingCompletedProducer', 'phase3ContainerDocuments', 'orchestrationFixtures', 'oceanBooking',
|
||||
'phase3CustomsSupplier', 'phase3Customs', 'serviceCenterFlow', 'portSchedule',
|
||||
'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady',
|
||||
]
|
||||
const contextSnapshot = new Map(contextKeys.map((key) => [key, {
|
||||
present: Object.hasOwn(this.context, key), value: this.context[key],
|
||||
}]))
|
||||
@@ -999,6 +1200,10 @@ export class AcceptanceRun {
|
||||
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
|
||||
}]))
|
||||
let restoreFixture = null
|
||||
// Each producer variant must create and advance its own loading, outbound and carrier facts.
|
||||
// Keeping the outer run's objects here can make a later container write PICKUP_READY nodes
|
||||
// to an earlier carrier task while still reporting the variant as ready.
|
||||
for (const key of createdKeys) delete this.createdResources[key]
|
||||
this.marker = variantMarker
|
||||
this.context.orderDestinationType = destinationType
|
||||
this.context.orderFulfillmentType = fulfillmentType
|
||||
@@ -1009,6 +1214,47 @@ export class AcceptanceRun {
|
||||
await this.executeModule(`phase3-loading-phase4-${label}`, () => loading.run(this))
|
||||
const finalize = await import(new URL('./admin-test-env-modules/phase3-loading-finalize.mjs', import.meta.url))
|
||||
await this.executeModule(`phase3-loading-finalize-phase4-${label}`, () => finalize.run(this))
|
||||
const documents = await import(new URL('./admin-test-env-modules/phase3-container-documents.mjs', import.meta.url))
|
||||
await this.executeModule(`phase3-container-documents-phase4-${label}`, () => documents.run(this))
|
||||
const producerKey = this.createdResources.phase3LoadingCompletedProducer?.resourceKey
|
||||
const writeShippingEvidence = (shippingReady, carrierScheduleQueryId = null) => {
|
||||
const matrix = this.createdResources.phase4InboundProducerFixtures
|
||||
const records = [matrix?.containers, matrix?.byDestinationType?.FBA,
|
||||
matrix?.byDestinationType?.OVERSEAS].filter(Array.isArray)
|
||||
for (const values of records) {
|
||||
const target = values.find((item) => item?.resourceKey === producerKey)
|
||||
if (target) Object.assign(target, { shippingReady, carrierScheduleQueryId })
|
||||
}
|
||||
}
|
||||
if (producerKey) writeShippingEvidence(false)
|
||||
if (pickupRequired && !producerKey) {
|
||||
this.assert('phase4-overseas-pda-fixture', `${label}产生者身份完整`, null, false,
|
||||
'phase3LoadingCompletedProducer.resourceKey=missing',
|
||||
{ scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
|
||||
}
|
||||
if (pickupRequired && producerKey) {
|
||||
const customs = await import(new URL('./admin-test-env-modules/phase3-customs.mjs', import.meta.url))
|
||||
await this.executeModule(`phase3-customs-phase4-${label}`, () => customs.run(this))
|
||||
const serviceCenter = await import(new URL('./admin-test-env-modules/service-center-flow.mjs', import.meta.url))
|
||||
await this.executeModule(`service-center-flow-phase4-${label}`, () => serviceCenter.run(this))
|
||||
const portSchedules = await import(new URL('./admin-test-env-modules/phase3-port-schedules.mjs', import.meta.url))
|
||||
await this.executeModule(`phase3-port-schedules-phase4-${label}`, () => portSchedules.run(this))
|
||||
const outbound = await import(new URL('./admin-test-env-modules/phase3-outbound-dispatch.mjs', import.meta.url))
|
||||
await this.executeModule(`phase3-outbound-dispatch-phase4-${label}`, () => outbound.run(this))
|
||||
const carrierQueries = await import(new URL('./admin-test-env-modules/phase3-carrier-schedule-queries.mjs', import.meta.url))
|
||||
await this.executeModule(`phase3-carrier-schedule-queries-phase4-${label}`, () => carrierQueries.run(this))
|
||||
const carrierNodes = await import(new URL('./admin-test-env-modules/phase4-carrier-nodes.mjs', import.meta.url))
|
||||
await this.executeModule(`phase4-carrier-pickup-ready-phase4-${label}`,
|
||||
() => carrierNodes.runUntilPickupReady(this))
|
||||
const pickup = this.createdResources.phase4CarrierPickupReady
|
||||
const shippingReady = pickup?.marker === variantMarker && pickup?.ready === true
|
||||
&& Boolean(pickup?.queryId)
|
||||
writeShippingEvidence(shippingReady, shippingReady ? String(pickup.queryId) : null)
|
||||
this.assert('phase4-overseas-pda-fixture', `${label}通过真实出仓与船节点进入PICKUP_READY`, null,
|
||||
shippingReady,
|
||||
`resourceKey=${producerKey}, queryId=${pickup?.queryId || 'missing'}, ready=${pickup?.ready === true}`,
|
||||
{ scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
|
||||
}
|
||||
} finally {
|
||||
if (restoreFixture) restoreFixture()
|
||||
this.marker = matrixMarker
|
||||
@@ -1026,21 +1272,32 @@ export class AcceptanceRun {
|
||||
async ensurePhase4PdaProducerMatrix() {
|
||||
const readiness = () => this.createdResources.phase4InboundProducerFixtures?.readiness || {}
|
||||
if (Number(readiness().dropshipContainerCount || 0) < 1) {
|
||||
await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP')
|
||||
await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true)
|
||||
}
|
||||
if (Number(readiness().fbaContainerCount || 0) < 2) {
|
||||
await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA')
|
||||
await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', false)
|
||||
}
|
||||
if (Number(readiness().overseasContainerCount || 0) < 1) {
|
||||
await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS')
|
||||
await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true)
|
||||
}
|
||||
if (Number(readiness().distinctContainerCount || 0) < 4) {
|
||||
await this.runPhase4ProducerVariant('SECONDARY', 'FBA')
|
||||
await this.runPhase4ProducerVariant('SECONDARY', 'FBA', 'STANDARD', false)
|
||||
}
|
||||
const matrix = this.createdResources.phase4InboundProducerFixtures
|
||||
const containers = Array.isArray(matrix?.containers) ? matrix.containers : []
|
||||
const dropshipReady = containers.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
||||
const overseasReady = containers.find((item) => item?.destinationType === 'OVERSEAS')
|
||||
const awaitingVariants = containers.filter((item) => ['FBA-STANDARD', 'SECONDARY']
|
||||
.some((label) => String(item?.resourceKey || '').includes(`-P4-${label}:`)))
|
||||
const shippingMatrixReady = dropshipReady?.shippingReady === true
|
||||
&& Boolean(dropshipReady?.carrierScheduleQueryId)
|
||||
&& overseasReady?.shippingReady === true && Boolean(overseasReady?.carrierScheduleQueryId)
|
||||
&& awaitingVariants.length >= 1
|
||||
&& awaitingVariants.every((item) => item.shippingReady === false
|
||||
&& item.carrierScheduleQueryId == null)
|
||||
this.assert('phase4-overseas-pda-fixture', '本轮三期跨区生产矩阵已就绪', null,
|
||||
matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true,
|
||||
`marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, DROPSHIP=${matrix?.readiness?.dropshipContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}`,
|
||||
matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true && shippingMatrixReady,
|
||||
`marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, DROPSHIP=${matrix?.readiness?.dropshipContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}, shippingReady=${containers.filter((item) => item?.shippingReady === true).length}, awaiting=${awaitingVariants.length}`,
|
||||
{ scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
|
||||
}
|
||||
|
||||
@@ -1100,12 +1357,20 @@ export class AcceptanceRun {
|
||||
}
|
||||
|
||||
async run() {
|
||||
if (this.options.merchantUsername && this.options.merchantPassword) {
|
||||
process.env.MERCHANT_TEST_USERNAME = this.options.merchantUsername
|
||||
process.env.MERCHANT_TEST_PASSWORD = this.options.merchantPassword
|
||||
}
|
||||
if (this.options.waitSplitCount > 1) this.applyWaitSplitVariant(1)
|
||||
const loginResponse = await this.request('POST', '/api/auth/login', { username: this.options.username, password: this.options.password }, '')
|
||||
const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse)
|
||||
if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块'
|
||||
else {
|
||||
this.token = login.accessToken
|
||||
this.refreshToken = login.refreshToken || ''
|
||||
this.overseasToken = login.overseasAccessToken || ''
|
||||
this.overseasExpiresAt = Number(login.overseasExpiresIn) > 0
|
||||
? Date.now() + Number(login.overseasExpiresIn) * 1000 : 0
|
||||
const requiresOverseas = this.options.modules.some((module) => module.startsWith('phase4-overseas-'))
|
||||
if (requiresOverseas) {
|
||||
this.assert('authentication', '登录响应包含有效海外访问令牌', loginResponse,
|
||||
@@ -1116,6 +1381,10 @@ export class AcceptanceRun {
|
||||
const handlers = { customers: () => this.runCustomers() }
|
||||
for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) {
|
||||
const module = this.options.modules[moduleIndex]
|
||||
if (module.startsWith('phase4-overseas-')
|
||||
&& this.overseasExpiresAt > 0 && Date.now() + 120_000 >= this.overseasExpiresAt) {
|
||||
await this.refreshSession(`海外模块 ${module} 执行前Token临近到期`)
|
||||
}
|
||||
const fullFinanceChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'].every((name) => this.options.modules.includes(name))
|
||||
if (module === 'finance' && fullFinanceChain) {
|
||||
this.modules.push({ name: module, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), success: true, passed: 0, failed: 0, fatal: null, skipped: 'finance-full-chain owns the finance source locks' })
|
||||
@@ -1179,6 +1448,22 @@ export class AcceptanceRun {
|
||||
} finally {
|
||||
if (restoreFixture) restoreFixture()
|
||||
}
|
||||
if (module === 'inbound-measurement' && this.captureWaitSplitFixture(1)) {
|
||||
await this.createAdditionalWaitSplitFixtures()
|
||||
}
|
||||
const laterOverseasModule = this.options.modules.slice(moduleIndex + 1)
|
||||
.some((name) => name.startsWith('phase4-overseas-'))
|
||||
if (module === 'system' && laterOverseasModule) {
|
||||
const refreshed = await this.refreshSession('system模块权限与角色变更完成')
|
||||
if (refreshed) {
|
||||
const probe = await this.requestOverseas('GET',
|
||||
'/api/platform/v1/last-leg/orders?page=1&size=1')
|
||||
this.assert('authentication', 'system后新admin海外Token只读探针可用', probe,
|
||||
probe.http === 200 && String(probe.body?.code) === '200',
|
||||
`HTTP=${probe.http}, code=${probe.body?.code ?? 'missing'}, requestId=${probe.requestId || 'missing'}, traceId=${probe.traceId || 'missing'}`,
|
||||
{ scenario: 'dual-token-refresh', deploymentBlocker: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const reportSurface = JSON.stringify({
|
||||
@@ -1186,7 +1471,9 @@ export class AcceptanceRun {
|
||||
deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps,
|
||||
createdResources: this.createdResources,
|
||||
})
|
||||
const forbiddenValues = [this.options.password, this.token, this.overseasToken].filter((value) => typeof value === 'string' && value.length >= 8)
|
||||
const forbiddenValues = [this.options.password, this.options.merchantPassword,
|
||||
this.token, this.refreshToken, this.overseasToken]
|
||||
.filter((value) => typeof value === 'string' && value.length >= 8)
|
||||
const sensitivePatterns = [
|
||||
/"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i,
|
||||
/\bBearer\s+[A-Za-z0-9._~-]{16,}/i,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
|
||||
|
||||
需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。
|
||||
|
||||
例如 `--modules system,inbound-weight-ocr` 会依次加载:
|
||||
|
||||
- `system.mjs`
|
||||
@@ -47,7 +49,7 @@ export async function run(context) {
|
||||
| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
|
||||
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
|
||||
| 入仓自动头程费用、派生详情、人工发布与 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-customs.mjs`、`phase3-loading-finalize.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-producer-chain-contract.test.mjs`、`manual-state-loading-pda-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` |
|
||||
| 订舱共享主数据前置与订舱验收 | `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` |
|
||||
@@ -57,6 +59,7 @@ export async function run(context) {
|
||||
| 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
|
||||
| 四期海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行 P2 盘点、P3 整托移库、P4 托盘拆装换绑、P5 换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
|
||||
| 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
|
||||
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(四类柜资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
|
||||
| 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
| 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` |
|
||||
| 四期船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` |
|
||||
@@ -66,6 +69,8 @@ export async function run(context) {
|
||||
|
||||
四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 WAREHOUSE_PICKER 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
|
||||
|
||||
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定经过本轮 `LOADED` 柜次的 `DESTINATION_CLEARANCE`、`CUSTOMS_DECLARATION`、`DELIVERY_ORDER`、`PICKUP_ORDER` 四类资料生命周期,再确认国内出仓、核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。四类资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -30,6 +30,16 @@ test('系统验收覆盖后端四种权限资源类型并清理测试资源', ()
|
||||
assert.match(system, /DELETE.*permissions\/\$\{additionalPermissionId\}/s)
|
||||
})
|
||||
|
||||
test('system后刷新双Token且受限海外权限只接受403语义', () => {
|
||||
assert.match(acceptance, /module === 'system' && laterOverseasModule/)
|
||||
assert.match(acceptance, /refreshSession\('system模块权限与角色变更完成'\)/)
|
||||
assert.match(acceptance, /system后新admin海外Token只读探针可用/)
|
||||
assert.match(acceptance, /Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/)
|
||||
assert.match(system, /缺少canonical海外订单权限必须403/)
|
||||
assert.match(system, /denied\.http === 403 \|\| \['403', 'A0403'\]\.includes/)
|
||||
assert.doesNotMatch(system, /A0401[^\r\n]{0,120}(必须403|includes)/)
|
||||
})
|
||||
|
||||
test('客户验收覆盖客户维度操作日志列表和详情快照', () => {
|
||||
assert.match(acceptance, /audit\/logs\?accountId=\$\{tenantId\}&eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId=\$\{tenantId\}/)
|
||||
assert.match(acceptance, /PLATFORM_TENANT_UPDATE/)
|
||||
|
||||
@@ -5,8 +5,8 @@ const BASE = '/api/platform/v1/fee-items'
|
||||
const BASELINE_COUNT = 111
|
||||
const DOMESTIC_COUNT = 84
|
||||
const INTERNATIONAL_COUNT = 27
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
|
||||
|
||||
@@ -7,10 +7,12 @@ import { fileURLToPath } from 'node:url'
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'finance-demo-fixtures.mjs'), 'utf8')
|
||||
|
||||
test('演示数据只消费本轮固定 marker 和客户 10001', () => {
|
||||
test('演示数据只消费本轮固定 marker 和动态正式客户 fixture', () => {
|
||||
assert.match(source, /const MARKER = 'FIN-DEMO-20260812'/)
|
||||
assert.match(source, /fixture\.marker !== runMarker/)
|
||||
assert.match(source, /fixture\.customerId !== '10001'/)
|
||||
assert.match(source, /customerId !== id\(qualificationFixture\?\.tenantId\)/)
|
||||
assert.match(source, /context\.context\.merchantQualificationFixture/)
|
||||
assert.doesNotMatch(source, /customerId:\s*['"]?10001|accountId=10001/)
|
||||
assert.match(source, /const runMarker = `\$\{MARKER\}-/)
|
||||
assert.match(source, /禁止借历史来源/)
|
||||
assert.doesNotMatch(source, /DELETE|cleanup|remove/i)
|
||||
@@ -41,8 +43,8 @@ test('所有财务写操作使用 marker 幂等键并上传真实凭证', () =>
|
||||
})
|
||||
|
||||
test('额外应付审核路径先追加 feeId 再追加 accountId 查询参数', () => {
|
||||
assert.match(source, /`\$\{basePath\}\/\$\{created\.id\}\/approve\?accountId=10001`/)
|
||||
assert.doesNotMatch(source, /fee-details\?accountId=10001\/\$\{created\.id\}/)
|
||||
assert.match(source, /`\$\{basePath\}\/\$\{created\.id\}\/approve\?accountId=\$\{encodeURIComponent\(customerId\)\}`/)
|
||||
assert.doesNotMatch(source, /fee-details\?accountId=.*\/\$\{created\.id\}/)
|
||||
})
|
||||
|
||||
test('费用来源使用 operationVersion,应付供应商长整型保持字符串精度', () => {
|
||||
|
||||
@@ -30,10 +30,10 @@ function blocker(context, name, response, evidence, category) {
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForSources(context, sourceFeeIds) {
|
||||
async function waitForSources(context, customerId, sourceFeeIds) {
|
||||
let pool = []
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
const response = await context.request('GET', `${BASE}/fee-pool?customerId=10001&direction=ALL`)
|
||||
const response = await context.request('GET', `${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`)
|
||||
pool = rows(data(response)).concat(rows(data(response)?.items))
|
||||
const visible = new Set(pool.map((item) => id(item.sourceId)))
|
||||
if (sourceFeeIds.every((sourceId) => visible.has(sourceId))) return pool
|
||||
@@ -80,10 +80,11 @@ async function createExtraPayable(context, fixture, index) {
|
||||
unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY',
|
||||
supplierId, remark: `${MARKER}-AP-${index}` }
|
||||
const basePath = `/api/platform/v1/inbounds/${inboundId}/fee-details`
|
||||
const createdResponse = await write(context, 'POST', `${basePath}?accountId=10001`, body, `extra-payable-${index}`)
|
||||
const customerId = id(fixture.customerId)
|
||||
const createdResponse = await write(context, 'POST', `${basePath}?accountId=${encodeURIComponent(customerId)}`, body, `extra-payable-${index}`)
|
||||
const created = data(createdResponse)
|
||||
if (!ok(createdResponse) || !created?.id) return null
|
||||
const approveResponse = await write(context, 'POST', `${basePath}/${created.id}/approve?accountId=10001`, undefined, `extra-payable-${index}-approve`)
|
||||
const approveResponse = await write(context, 'POST', `${basePath}/${created.id}/approve?accountId=${encodeURIComponent(customerId)}`, undefined, `extra-payable-${index}-approve`)
|
||||
record(context, `创建第 ${index} 条额外应付来源`, approveResponse, ok(approveResponse),
|
||||
`sourceFeeId=${created.id}, inboundId=${inboundId}, supplierId=${supplierId}`)
|
||||
return { sourceFeeId: id(created.id), direction: 'PAYABLE', businessDate: body.businessDate,
|
||||
@@ -91,7 +92,7 @@ async function createExtraPayable(context, fixture, index) {
|
||||
}
|
||||
|
||||
async function createStatement(context, source, role, targetStatus) {
|
||||
const body = { customerId: '10001', currency: 'CNY', generationMode: 'SELECTED',
|
||||
const body = { customerId: id(source.customerId), currency: 'CNY', generationMode: 'SELECTED',
|
||||
feeSourceIds: [source.feeSourceId], periodStart: source.businessDate, periodEnd: source.businessDate,
|
||||
remark: `${MARKER}-${role}` }
|
||||
const response = await write(context, 'POST', `${BASE}/statements`, body, `statement-${role}-create`)
|
||||
@@ -120,7 +121,7 @@ async function createAr(context, statement, role) {
|
||||
|
||||
async function receive(context, fixture, settlement, amount, role) {
|
||||
const response = await write(context, 'POST', `${BASE}/receipts`, {
|
||||
customerId: 10001, receivedDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY',
|
||||
customerId: id(fixture.customerId), receivedDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY',
|
||||
paymentMethod: 'BANK_TRANSFER', voucherFileIds: [fixture.voucherFileId], remark: `${MARKER}-${role}`,
|
||||
allocations: [{ billId: Number(settlement.reconciliationStatementId), amount }],
|
||||
}, `receipt-${role}`)
|
||||
@@ -130,7 +131,7 @@ async function receive(context, fixture, settlement, amount, role) {
|
||||
|
||||
async function createAp(context, fixture, source, role) {
|
||||
const response = await write(context, 'POST', `${BASE}/payable-settlements`, {
|
||||
customerId: 10001, supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)],
|
||||
customerId: id(fixture.customerId), supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)],
|
||||
dueDate: new Date().toISOString().slice(0, 10), currency: 'CNY',
|
||||
}, `ap-${role}-${source.feeSourceId}-${source.supplierId}-create`)
|
||||
const value = data(response)
|
||||
@@ -165,8 +166,9 @@ async function recoverExistingDemo(context, reportPath) {
|
||||
const report = JSON.parse(await readFile(reportPath, 'utf8'))
|
||||
const fixture = report?.createdResources?.financeFullFixtures
|
||||
const matrix = structuredClone(report?.createdResources?.financeDemo ?? {})
|
||||
if (fixture?.marker !== matrix.runMarker || matrix.marker !== MARKER || matrix.customerId !== '10001') {
|
||||
throw new Error('恢复报告不是 FIN-DEMO-20260812 / 客户 10001 的同一轮 fixture')
|
||||
if (fixture?.marker !== matrix.runMarker || matrix.marker !== MARKER
|
||||
|| !id(matrix.customerId) || id(matrix.customerId) !== id(fixture.customerId)) {
|
||||
throw new Error('恢复报告不是 FIN-DEMO-20260812 同一轮动态客户 fixture')
|
||||
}
|
||||
context.marker = matrix.runMarker
|
||||
await verifyUnfilteredLists(context)
|
||||
@@ -189,7 +191,7 @@ async function recoverExistingDemo(context, reportPath) {
|
||||
const role = roles[index]
|
||||
const detail = await sourceDetail(context, payableIds[index])
|
||||
const source = detail.value
|
||||
if (!ok(detail.response) || source?.direction !== 'PAYABLE' || source?.customerId !== '10001'
|
||||
if (!ok(detail.response) || source?.direction !== 'PAYABLE' || id(source?.customerId) !== id(matrix.customerId)
|
||||
|| !id(source?.sourceFeeId)) {
|
||||
blocker(context, `${role} 恢复来源属于本轮 fixture`, detail.response,
|
||||
`feeSourceId=${payableIds[index]}, sourceFeeId=${source?.sourceFeeId ?? 'missing'}`,
|
||||
@@ -276,9 +278,12 @@ export async function run(context) {
|
||||
const producer = await import('./finance-full-fixtures.mjs')
|
||||
await producer.run(context)
|
||||
const fixture = context.createdResources.financeFullFixtures
|
||||
if (!fixture || fixture.marker !== runMarker || fixture.customerId !== '10001') {
|
||||
const qualificationFixture = context.context.merchantQualificationFixture
|
||||
const customerId = id(fixture?.customerId)
|
||||
if (!fixture || fixture.marker !== runMarker || !customerId
|
||||
|| customerId !== id(qualificationFixture?.tenantId)) {
|
||||
blocker(context, '取得演示企业真实 Finance fixture', null,
|
||||
'finance-full-fixtures 未返回 marker=FIN-DEMO-20260812、customerId=10001 的独立资源,禁止借历史来源',
|
||||
'finance-full-fixtures 未返回与 merchant-qualification-fixture 同客户的本轮独立资源,禁止借历史来源',
|
||||
'missing-finance-demo-fixture')
|
||||
return
|
||||
}
|
||||
@@ -289,10 +294,10 @@ export async function run(context) {
|
||||
if (value) extraPayables.push(value)
|
||||
}
|
||||
const produced = [...fixture.feeSources.map((item) => ({ ...item, sourceFeeId: id(item.feeSourceId) })), ...extraPayables]
|
||||
const pool = await waitForSources(context, produced.map((item) => item.sourceFeeId).filter(Boolean))
|
||||
const pool = await waitForSources(context, customerId, produced.map((item) => item.sourceFeeId).filter(Boolean))
|
||||
const poolBySource = new Map(pool.map((item) => [id(item.sourceId), item]))
|
||||
const sources = produced.map((source) => ({ ...source,
|
||||
feeSourceId: financeId(poolBySource.get(source.sourceFeeId)), marker: runMarker,
|
||||
feeSourceId: financeId(poolBySource.get(source.sourceFeeId)), marker: runMarker, customerId,
|
||||
supplierId: id(source.supplierId ?? fixture.supplier?.supplierId) }))
|
||||
.filter((source) => source.feeSourceId)
|
||||
const receivables = sources.filter((source) => source.direction === 'RECEIVABLE')
|
||||
@@ -304,7 +309,8 @@ export async function run(context) {
|
||||
return
|
||||
}
|
||||
|
||||
const matrix = { marker: MARKER, runMarker, customerId: '10001', customerLabel: '一期演示企业 10001',
|
||||
const matrix = { marker: MARKER, runMarker, customerId,
|
||||
customerLabel: qualificationFixture?.tenantName || `动态验收客户 ${customerId}`,
|
||||
feeSources: {}, statements: {}, receivableSettlements: {}, payableSettlements: {},
|
||||
voucherFileId: id(fixture.voucherFileId), container: fixture.container, allocations: fixture.allocations }
|
||||
|
||||
|
||||
@@ -146,22 +146,23 @@ export function customsCaseFieldGaps(record) {
|
||||
|
||||
export function outboundAggregateFieldGaps(record) {
|
||||
const aggregate = record?.aggregate ?? record
|
||||
return gaps(aggregate, {
|
||||
orderId: isPositiveId,
|
||||
inboundId: isPositiveId,
|
||||
inboundCode: isNonBlank,
|
||||
customerId: isPositiveId,
|
||||
customerName: isNonBlank,
|
||||
const result = gaps(aggregate, {
|
||||
containerBookingId: isPositiveId,
|
||||
containerNo: isNonBlank,
|
||||
loadingPlanId: isPositiveId,
|
||||
loadingPlanStatus: isNonBlank,
|
||||
pickTaskId: isPositiveId,
|
||||
pickTaskStatus: isNonBlank,
|
||||
oceanBookingId: isPositiveId,
|
||||
oceanBookingStatus: isNonBlank,
|
||||
dispatchStatus: isNonBlank,
|
||||
version: isNonNegativeNumber,
|
||||
'gate.items': (value) => Array.isArray(value),
|
||||
inbounds: (value) => Array.isArray(value) && value.length > 0,
|
||||
'gate.inbounds': (value) => Array.isArray(value) && value.length > 0,
|
||||
})
|
||||
for (const [index, inbound] of (Array.isArray(aggregate?.inbounds) ? aggregate.inbounds : []).entries()) {
|
||||
result.push(...gaps(inbound, {
|
||||
orderId: isPositiveId, inboundId: isPositiveId, inboundCode: isNonBlank,
|
||||
tenantId: isPositiveId, inboundStatus: isNonBlank,
|
||||
}).map((field) => `inbounds[${index}].${field}`))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
|
||||
[portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'],
|
||||
[truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', customerId: ids.id, customerName: '测试客户', carrierId: ids.id, carrierName: '拖车商', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'],
|
||||
[customsCaseFieldGaps, { customsCase: { id: ids.id, customsNo: 'CUS-1', orderId: ids.order, inboundId: ids.inbound, containerBookingId: ids.container, checklistVersionId: ids.id, checklistCode: 'CHECK', checklistVersionNo: 'V1', status: 'DOCUMENTS_PENDING', updatedAt: at } }, undefined, 'checklistCode'],
|
||||
[outboundAggregateFieldGaps, { orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', customerId: ids.id, customerName: '测试客户', containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, gate: { items: [] } }, undefined, 'inboundCode'],
|
||||
[outboundAggregateFieldGaps, { containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, inbounds: [{ orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', tenantId: ids.id, inboundStatus: 'LOADED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED' }], gate: { inbounds: [{ inboundId: ids.inbound, inboundCode: 'IN-1', gate: { items: [] } }] } }, undefined, 'containerNo'],
|
||||
]
|
||||
for (const [validator, sample, options, missing] of samples) {
|
||||
assert.deepEqual(validator(sample, options), [])
|
||||
|
||||
@@ -22,7 +22,9 @@ test('measurement submission confirms weight evidence before submit', () => {
|
||||
})
|
||||
|
||||
test('measurement fixture derives pallet resources from confirmed image count', () => {
|
||||
assert.match(source, /palletCount: WEIGHING_IMAGE_COUNT/)
|
||||
assert.match(source, /waitSplitVariant\?\.palletCount \|\| 1/)
|
||||
assert.match(source, /palletIds: warehouseResourcesReady\.palletIds/)
|
||||
assert.match(source, /confirmedRows\.length === palletIds\.length/)
|
||||
assert.doesNotMatch(source, /Math\.ceil\(Number\(totalVolumeCbm\)/)
|
||||
assert.doesNotMatch(source, /Math\.ceil\(totalVolume \/ DEFAULT_PALLET_CAPACITY_CBM\)/)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const MODULE = 'inbound-measurement'
|
||||
const SCENARIO = 'mojia-carton-measurement'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
const WEIGHING_IMAGE_COUNT = 1
|
||||
const FIXTURE_PALLET_CAPACITY_CBM = 1.2
|
||||
const WEIGHT_IMAGE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82,
|
||||
0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84,
|
||||
@@ -44,16 +43,17 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa
|
||||
if (!warehouseId || !Number.isInteger(palletCount) || palletCount < 1) return null
|
||||
|
||||
const suffix = `${String(context.marker).replace(/\D/g, '').slice(-8)}${String(inboundId).slice(-6)}`
|
||||
const palletResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', {
|
||||
warehouseId, palletType: 'STANDARD', capacityCbm: FIXTURE_PALLET_CAPACITY_CBM,
|
||||
weightKg: 8, color: '蓝色', remark: `${context.marker}-INBOUND-${inboundId}`,
|
||||
})
|
||||
const palletId = asId(context.apiSuccess(MODULE, '创建称重托盘主数据', palletResponse,
|
||||
`warehouseId=${warehouseId}`, OPTIONS))
|
||||
if (!palletId) return null
|
||||
|
||||
const resources = []
|
||||
const palletIds = []
|
||||
for (let index = 1; index <= palletCount; index += 1) {
|
||||
const palletResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', {
|
||||
warehouseId, palletType: 'STANDARD', capacityCbm: FIXTURE_PALLET_CAPACITY_CBM,
|
||||
weightKg: 8, color: '蓝色', remark: `${context.marker}-INBOUND-${inboundId}-PALLET-${index}`,
|
||||
})
|
||||
const palletId = asId(context.apiSuccess(MODULE, `创建称重托盘主数据 ${index}/${palletCount}`,
|
||||
palletResponse, `warehouseId=${warehouseId}`, OPTIONS))
|
||||
if (!palletId) return null
|
||||
palletIds.push(palletId)
|
||||
const resourceSuffix = `${suffix}${String(index).padStart(2, '0')}`
|
||||
let locationId = null
|
||||
if (context.context?.phase3LoadingRequireSharedOverflow !== true) {
|
||||
@@ -75,54 +75,65 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa
|
||||
marker: context.marker, tenantId: '0', warehouseId: asId(warehouseId), palletCount,
|
||||
palletCapacityCbm: FIXTURE_PALLET_CAPACITY_CBM, resources,
|
||||
}
|
||||
return { palletId, palletCount, resources }
|
||||
return { palletId: palletIds[0], palletIds, palletCount, resources }
|
||||
}
|
||||
|
||||
async function createConfirmedWeightEvidence(context, { inboundId, tenantId, palletId }) {
|
||||
const uploadResponse = await context.requestMultipart('POST',
|
||||
`/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, {
|
||||
content: WEIGHT_IMAGE, type: 'image/png', name: `weight-${inboundId}.png`,
|
||||
})
|
||||
const upload = context.apiSuccess(MODULE, '上传称重图片并执行 OCR', uploadResponse,
|
||||
`inboundId=${inboundId}`, OPTIONS)
|
||||
const fileId = asId(upload?.fileId)
|
||||
if (!fileId) return null
|
||||
async function createConfirmedWeightEvidence(context, {
|
||||
inboundId, tenantId, palletIds, grossWeightPerPalletKg,
|
||||
}) {
|
||||
if (!Array.isArray(palletIds) || palletIds.length < 1) return null
|
||||
const confirmedResults = []
|
||||
for (let index = 0; index < palletIds.length; index += 1) {
|
||||
const palletId = palletIds[index]
|
||||
const uploadResponse = await context.requestMultipart('POST',
|
||||
`/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, {
|
||||
content: WEIGHT_IMAGE, type: 'image/png', name: `weight-${inboundId}-${index + 1}.png`,
|
||||
})
|
||||
const upload = context.apiSuccess(MODULE, `上传称重图片并执行 OCR ${index + 1}/${palletIds.length}`,
|
||||
uploadResponse, `inboundId=${inboundId}`, OPTIONS)
|
||||
const fileId = asId(upload?.fileId)
|
||||
if (!fileId) return null
|
||||
|
||||
const createResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results?${query(tenantId)}`, {
|
||||
fileId, palletId, ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null,
|
||||
ocrWeightValue: null, ocrWeightUnit: null, confidence: null,
|
||||
})
|
||||
const created = context.apiSuccess(MODULE, '登记称重图片与托盘快照', createResponse,
|
||||
`fileId=${fileId}, palletId=${palletId}`, OPTIONS)
|
||||
if (!created?.id) return null
|
||||
const createResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results?${query(tenantId)}`, {
|
||||
fileId, palletId, ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null,
|
||||
ocrWeightValue: null, ocrWeightUnit: null, confidence: null,
|
||||
})
|
||||
const created = context.apiSuccess(MODULE, `登记称重图片与托盘快照 ${index + 1}/${palletIds.length}`,
|
||||
createResponse, `fileId=${fileId}, palletId=${palletId}`, OPTIONS)
|
||||
if (!created?.id) return null
|
||||
|
||||
const confirmResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, {
|
||||
version: Number(created.version), adjustedWeightValue: 100,
|
||||
adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 自动化人工确认称重`,
|
||||
})
|
||||
const confirmed = context.apiSuccess(MODULE, '人工确认称重图片净重', confirmResponse,
|
||||
`resultId=${created.id}, palletId=${palletId}`, OPTIONS)
|
||||
const netWeightKg = Number(confirmed?.netWeightKg)
|
||||
const grossWeightKg = Number(confirmed?.grossWeightKg)
|
||||
if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) {
|
||||
return null
|
||||
const grossWeight = Number(grossWeightPerPalletKg) + index * 5
|
||||
const confirmResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, {
|
||||
version: Number(created.version), adjustedWeightValue: grossWeight,
|
||||
adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 自动化人工确认称重`,
|
||||
})
|
||||
const confirmed = context.apiSuccess(MODULE, `人工确认称重图片净重 ${index + 1}/${palletIds.length}`,
|
||||
confirmResponse, `resultId=${created.id}, palletId=${palletId}`, OPTIONS)
|
||||
const netWeightKg = Number(confirmed?.netWeightKg)
|
||||
const grossWeightKg = Number(confirmed?.grossWeightKg)
|
||||
if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) {
|
||||
return null
|
||||
}
|
||||
confirmedResults.push({ id: asId(created.id), palletId, netWeightKg, grossWeightKg })
|
||||
}
|
||||
|
||||
const workspaceResponse = await context.request('GET',
|
||||
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`)
|
||||
const workspace = context.apiSuccess(MODULE, '回查已确认称重图片和货物总重量', workspaceResponse,
|
||||
`resultId=${created.id}`, OPTIONS)
|
||||
`resultIds=${confirmedResults.map((result) => result.id).join(',')}`, OPTIONS)
|
||||
const confirmedRows = (workspace?.weightResults || [])
|
||||
.filter((result) => result?.recognitionStatus === 'CONFIRMED')
|
||||
const persisted = confirmedRows.length === WEIGHING_IMAGE_COUNT
|
||||
&& asId(confirmedRows[0]?.id) === asId(created.id)
|
||||
&& asId(confirmedRows[0]?.palletId) === palletId
|
||||
const grossWeightKg = confirmedResults.reduce((sum, result) => sum + result.grossWeightKg, 0)
|
||||
const netWeightKg = confirmedResults.reduce((sum, result) => sum + result.netWeightKg, 0)
|
||||
const persisted = confirmedRows.length === palletIds.length
|
||||
&& confirmedResults.every((expected) => confirmedRows.some((row) =>
|
||||
asId(row?.id) === expected.id && asId(row?.palletId) === expected.palletId))
|
||||
&& closeTo(workspace?.actualGrossWeightKg, grossWeightKg, 0.001)
|
||||
&& closeTo(workspace?.actualNetWeightKg, netWeightKg, 0.001)
|
||||
context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted,
|
||||
`confirmedImages=${confirmedRows.length}, expectedPallets=${WEIGHING_IMAGE_COUNT}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`,
|
||||
`confirmedImages=${confirmedRows.length}, expectedPallets=${palletIds.length}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`,
|
||||
OPTIONS)
|
||||
return persisted ? { count: confirmedRows.length, grossWeightKg, netWeightKg } : null
|
||||
}
|
||||
@@ -280,8 +291,8 @@ export async function run(context) {
|
||||
}
|
||||
|
||||
const scanPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/carton-scans?${query(tenantId)}`
|
||||
// Keep the isolated sample inside the customer's largest configured inner box (60x50x50cm).
|
||||
const firstDimensions = { length: 59.1, width: 40.2, height: 30.3 }
|
||||
const measurementProfile = context.context?.waitSplitVariant?.measurementProfile
|
||||
const firstDimensions = measurementProfile?.first || { length: 59.1, width: 40.2, height: 30.3 }
|
||||
const firstShipmentId = `FBA-${context.marker.slice(-12)}-01`
|
||||
const firstScanResponse = await multipartRequest(context, scanPath, {
|
||||
boxCode, ...firstDimensions, scannedShipmentId: firstShipmentId,
|
||||
@@ -325,7 +336,7 @@ export async function run(context) {
|
||||
&& Number(countResult?.measurementVersion) === Number(firstScan.measurementVersion) + 1,
|
||||
`version=${countResult?.measurementVersion ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const replacement = { length: 59.5, width: 41.2, height: 31.3 }
|
||||
const replacement = measurementProfile?.replacement || { length: 59.5, width: 41.2, height: 31.3 }
|
||||
const replacementShipmentId = `FBA-${context.marker.slice(-12)}-02`
|
||||
const rescanResponse = await multipartRequest(context, scanPath, {
|
||||
boxCode, ...replacement, scannedShipmentId: replacementShipmentId,
|
||||
@@ -342,8 +353,7 @@ export async function run(context) {
|
||||
let measuredResponse = await context.request('GET', measurementPath)
|
||||
let measured = context.apiSuccess(MODULE, '回查覆盖后的尺寸与体积结果', measuredResponse, '', OPTIONS)
|
||||
let measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId))
|
||||
// The verified customer rule maps this scan to EXTRA_LARGE 60x50x50cm and adds 2cm per edge.
|
||||
const weightedOuter = { length: 64, width: 54, height: 54 }
|
||||
const weightedOuter = measurementProfile?.outer || { length: 64, width: 54, height: 54 }
|
||||
const unitVolume = halfUp(
|
||||
weightedOuter.length * weightedOuter.width * weightedOuter.height / 1_000_000, 3)
|
||||
const totalVolume = unitVolume * originalPackageCount
|
||||
@@ -374,7 +384,7 @@ export async function run(context) {
|
||||
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/items/${firstScan.itemId}/confirmed-package-count?${query(tenantId)}`,
|
||||
{ measurementVersion: Number(measured?.version), confirmedPackageCount: packagesPerDetail })
|
||||
const resetFirstCount = context.apiSuccess(MODULE,
|
||||
'增量 fixture 将首条货物确认箱数恢复为自身两箱', resetFirstCountResponse,
|
||||
'增量 fixture 将首条货物确认箱数恢复为自身箱数', resetFirstCountResponse,
|
||||
`itemId=${asId(firstScan.itemId)}, confirmedPackageCount=${packagesPerDetail}`, OPTIONS)
|
||||
context.assert(MODULE, '增量首条货物箱数和版本更新正确', resetFirstCountResponse,
|
||||
Number(resetFirstCount?.confirmedPackageCount) === packagesPerDetail
|
||||
@@ -409,10 +419,10 @@ export async function run(context) {
|
||||
`measurementVersion=${countDetail?.measurementVersion ?? 'missing'}`, OPTIONS)
|
||||
}
|
||||
measuredResponse = await context.request('GET', measurementPath)
|
||||
measured = context.apiSuccess(MODULE, '回查增量 fixture 四条测量明细', measuredResponse, '', OPTIONS)
|
||||
measured = context.apiSuccess(MODULE, '回查增量 fixture 动态测量明细', measuredResponse, '', OPTIONS)
|
||||
measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId))
|
||||
const detailIds = new Set((measured?.items || []).map((item) => asId(item?.inboundDetailId)).filter(Boolean))
|
||||
context.assert(MODULE, '增量 fixture 形成四条真实独立货物测量明细', measuredResponse,
|
||||
context.assert(MODULE, '增量 fixture 形成动态真实独立货物测量明细', measuredResponse,
|
||||
detailIds.size >= incrementalDetailCount
|
||||
&& measured.items.filter((item) => detailIds.has(asId(item?.inboundDetailId))).length >= incrementalDetailCount,
|
||||
`measurementItems=${measured?.items?.length ?? 'missing'}, distinctInboundDetails=${detailIds.size}`, OPTIONS)
|
||||
@@ -482,12 +492,13 @@ export async function run(context) {
|
||||
return
|
||||
}
|
||||
|
||||
const palletCount = Math.max(1, Number(context.context?.waitSplitVariant?.palletCount || 1))
|
||||
const warehouseResourcesReady = await provisionWarehouseResources(context, {
|
||||
inboundId, warehouseId: asId(summary?.warehouseId), palletCount: WEIGHING_IMAGE_COUNT,
|
||||
inboundId, warehouseId: asId(summary?.warehouseId), palletCount,
|
||||
})
|
||||
context.assert(MODULE, '测量提交前按称重图片数准备真实库位', draftResponse,
|
||||
Boolean(warehouseResourcesReady),
|
||||
`warehouseId=${asId(summary?.warehouseId) || 'missing'}, palletCount=${WEIGHING_IMAGE_COUNT}`,
|
||||
`warehouseId=${asId(summary?.warehouseId) || 'missing'}, palletCount=${palletCount}`,
|
||||
OPTIONS)
|
||||
if (!warehouseResourcesReady) {
|
||||
finishScenario(context, start)
|
||||
@@ -495,11 +506,12 @@ export async function run(context) {
|
||||
}
|
||||
|
||||
const weightEvidence = await createConfirmedWeightEvidence(context, {
|
||||
inboundId, tenantId, palletId: warehouseResourcesReady.palletId,
|
||||
inboundId, tenantId, palletIds: warehouseResourcesReady.palletIds,
|
||||
grossWeightPerPalletKg: Number(context.context?.waitSplitVariant?.grossWeightPerPalletKg || 100),
|
||||
})
|
||||
context.assert(MODULE, '测量提交前已通过正式接口确认称重图片', draftResponse,
|
||||
Boolean(weightEvidence),
|
||||
`confirmedImages=${weightEvidence?.count ?? 0}, expected=${WEIGHING_IMAGE_COUNT}`,
|
||||
`confirmedImages=${weightEvidence?.count ?? 0}, expected=${palletCount}`,
|
||||
OPTIONS)
|
||||
if (!weightEvidence) {
|
||||
finishScenario(context, start)
|
||||
|
||||
@@ -7,23 +7,23 @@ const iamSeed = readFileSync(new URL('../../iam/src/main/resources/db/phase1-adm
|
||||
const oceanBookingSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql', import.meta.url), 'utf8')
|
||||
const truckAppointmentSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql', import.meta.url), 'utf8')
|
||||
|
||||
test('统一初始化 SQL 保留最小登录与客户主体', () => {
|
||||
assert.match(sql, /一个平台系统管理员和一个 IT 测试客户管理员/)
|
||||
test('统一初始化 SQL 只保留平台管理员且不创建固定客户', () => {
|
||||
assert.match(sql, /最小登录基线只保留平台系统管理员/)
|
||||
assert.equal((sql.match(/'platform_admin'/g) || []).length, 1)
|
||||
assert.equal((sql.match(/'merchant_admin'/g) || []).length, 1)
|
||||
assert.match(sql, /\(10001, 'IT_TEST', 'IT 测试客户'/)
|
||||
assert.doesNotMatch(sql, /'merchant_admin'|'IT_TEST'|'IT 测试客户'/)
|
||||
assert.doesNotMatch(iamSeed, /'IT_TEST'|'IT 测试客户'|\(10001,/)
|
||||
const accountSeed = sql.match(/INSERT INTO auth_account \([\s\S]*?deleted = 0;/)?.[0]
|
||||
assert.ok(accountSeed, 'missing auth account seed')
|
||||
assert.doesNotMatch(accountSeed, /ON DUPLICATE KEY UPDATE[\s\S]*?password_hash\s*=/,
|
||||
're-running the unified SQL must not reset an existing account password')
|
||||
})
|
||||
|
||||
test('IT 测试客户初始化后具备已审核的必填资质和下单资格', () => {
|
||||
for (const source of [sql, iamSeed]) {
|
||||
assert.match(source, /\(2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL,[\s\S]*?'APPROVED'/)
|
||||
assert.match(source, /\(2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL,[\s\S]*?'APPROVED'/)
|
||||
assert.match(source, /status = 'APPROVED'/)
|
||||
}
|
||||
test('统一初始化直接提供tenant0商户角色与权限模板', () => {
|
||||
assert.match(sql, /4000100, 0, 'MERCHANT', 'MERCHANT_OWNER'/)
|
||||
assert.match(sql, /5300000, 0, 'MERCHANT', 'MENU',[\s\S]*?'merchant:enterprise'/)
|
||||
assert.match(sql, /5301024, 0, 'MERCHANT', 'BUTTON',[\s\S]*?'merchant:order:quick-create'/)
|
||||
assert.match(sql, /5305000, 0, 'MERCHANT', 'MENU',[\s\S]*?'merchant:finance:statement'/)
|
||||
assert.match(sql, /r\.id=4000100[\s\S]*?客户全局管理员 Finance 模板授权不完整/)
|
||||
})
|
||||
|
||||
test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => {
|
||||
@@ -32,6 +32,9 @@ test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () =>
|
||||
"'CAR-001'", "'CARRIER'", "'SHIPPER'", "'EXP-TEST'", "'IT 测试快递'",
|
||||
]) assert.ok(sql.includes(marker), `missing initialization marker: ${marker}`)
|
||||
assert.match(sql, /'EXP-TEST', 'IT 测试快递'[\s\S]*?'ACTIVE'/)
|
||||
assert.match(sql, /193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1,'16:00:00',[\s\S]*?'洛杉矶仓海外接柜入仓收货能力'/)
|
||||
assert.match(sql, /193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00',[\s\S]*?'纽约仓海外接柜入仓收货能力'/)
|
||||
assert.match(sql, /固定平台作业仓收货能力写入校验失败/)
|
||||
|
||||
const palletSeed = sql.match(/INSERT INTO wh_pallet \([\s\S]*?remark = VALUES\(remark\);/)?.[0]
|
||||
assert.ok(palletSeed, 'missing pallet initialization seed')
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test'
|
||||
|
||||
const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8')
|
||||
const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('默认全量验收先准备已审核商户再运行订单链路', () => {
|
||||
const modules = acceptance.match(/const CONFIGURED_MODULES = \[([^\n]+)\]/)?.[1] || ''
|
||||
@@ -15,9 +16,45 @@ test('默认全量验收先准备已审核商户再运行订单链路', () => {
|
||||
test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () => {
|
||||
assert.match(fixture, /files\/qualifications\/\$\{encodeURIComponent\(tenantId\)\}\/required-package/)
|
||||
assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/submit-review/)
|
||||
assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/dimension-initialization/)
|
||||
assert.match(fixture, /status === 'READY'/)
|
||||
assert.match(fixture, /setTimeout\(resolve, 500\)/)
|
||||
assert.match(fixture, /iam\/reviews\/\$\{encodeURIComponent\(review\.id\)\}\/approve/)
|
||||
assert.match(fixture, /item\.status === 'APPROVED'/)
|
||||
assert.match(fixture, /MERCHANT_TEST_USERNAME/)
|
||||
assert.match(fixture, /MERCHANT_TEST_PASSWORD/)
|
||||
assert.match(fixture, /context\.options\?\.customerName/)
|
||||
assert.doesNotMatch(fixture, /internal\/iam|UPDATE\s+iam_|INSERT\s+INTO\s+iam_/i)
|
||||
})
|
||||
|
||||
test('批量待分柜参数固定同一客户并限制为订单测量链', () => {
|
||||
assert.match(acceptance, /--customer-name <name>/)
|
||||
assert.match(acceptance, /--merchant-username <name>/)
|
||||
assert.match(acceptance, /--origin-warehouse-name <name>/)
|
||||
assert.match(acceptance, /--channel-name <name>/)
|
||||
assert.match(acceptance, /--wait-split-count <n>/)
|
||||
assert.match(acceptance, /--order-profile-mode <mode>/)
|
||||
assert.match(acceptance, /merchant-username和merchant-password必须同时提供/)
|
||||
assert.match(acceptance, /modules\.splice\(fixtureIndex, 1\)/)
|
||||
assert.match(acceptance, /wait-split-count必须是1-100的整数/)
|
||||
assert.match(acceptance, /批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement/)
|
||||
assert.match(acceptance, /createAdditionalWaitSplitFixtures/)
|
||||
assert.match(acceptance, /measurement\.processStatus === 'WAIT_SPLIT'/)
|
||||
assert.match(acceptance, /measurement\.finalStatus === 'APPROVED'/)
|
||||
assert.match(acceptance, /waitSplitVariant\(sequence\)/)
|
||||
assert.match(acceptance, /packageCountPerSku/)
|
||||
assert.match(acceptance, /grossWeightPerPalletKg/)
|
||||
assert.match(acceptance, /randomizedOrderProfile\(sequence\)/)
|
||||
assert.match(acceptance, /\['PRIVATE', 'STANDARD'\]/)
|
||||
assert.match(acceptance, /ONE_CLICK_DROPSHIP/)
|
||||
assert.match(acceptance, /destinationWarehouseCode: order\.destinationWarehouseCode/)
|
||||
assert.match(acceptance, /channelName: order\.channelName/)
|
||||
assert.match(acceptance, /pricingTaxMode: order\.pricingTaxMode/)
|
||||
assert.match(orders, /customerId: profile\.id/)
|
||||
assert.match(orders, /customerName: companyName/)
|
||||
assert.match(orders, /\['FBA', 'OVERSEAS', 'PRIVATE'\]\.includes/)
|
||||
assert.match(orders, /destinationType === 'PRIVATE'/)
|
||||
assert.match(orders, /PRIVATE_ADDRESS/)
|
||||
assert.match(orders, /eligible\.length < 1/)
|
||||
assert.doesNotMatch(orders, /customerName: master\.partner\?\.partnerName/)
|
||||
})
|
||||
|
||||
@@ -9,12 +9,30 @@ function id(value) {
|
||||
return value === undefined || value === null ? '' : String(value)
|
||||
}
|
||||
|
||||
async function waitForDimensionInitialization(context, tenantId) {
|
||||
let response = null
|
||||
let status = 'PENDING'
|
||||
let attempt = 0
|
||||
for (; attempt < 30; attempt += 1) {
|
||||
response = await context.request('GET',
|
||||
`/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/dimension-initialization`)
|
||||
status = response.http === 200 && String(response.body?.code) === '200'
|
||||
? response.body?.data?.status : 'REQUEST_FAILED'
|
||||
if (status === 'READY' || status === 'FAILED') break
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
context.assert(MODULE, '客户默认尺寸后台初始化已就绪', response, status === 'READY',
|
||||
`tenantId=${tenantId}, status=${status}, attempts=${attempt + 1}`, { deploymentBlocker: true })
|
||||
if (status !== 'READY') throw new Error(`客户默认尺寸初始化未就绪: ${status}`)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0')
|
||||
const customerName = String(context.options?.customerName || '').trim()
|
||||
const phone = await context.findAvailablePhone(MODULE)
|
||||
const createResponse = await context.request('POST', '/api/platform/v1/iam/tenants', {
|
||||
tenantName: `全量验收下单客户-${suffix}`,
|
||||
legalName: `全量验收下单有限公司-${suffix}`,
|
||||
tenantName: customerName || `全量验收下单客户-${suffix}`,
|
||||
legalName: customerName ? `${customerName}有限公司` : `全量验收下单有限公司-${suffix}`,
|
||||
legalRepresentative: '全量验收法人',
|
||||
contactName: '全量验收管理员',
|
||||
contactPhone: phone,
|
||||
@@ -63,6 +81,8 @@ export async function run(context) {
|
||||
`tenantId=${tenantId}, reviewId=${id(review?.id) || 'missing'}`, { deploymentBlocker: true })
|
||||
if (!review?.id) throw new Error('未找到隔离下单客户的待审核单')
|
||||
|
||||
await waitForDimensionInitialization(context, tenantId)
|
||||
|
||||
const approveResponse = await context.request('POST',
|
||||
`/api/platform/v1/iam/reviews/${encodeURIComponent(review.id)}/approve`,
|
||||
{ comment: `${context.marker} 全量验收正式资质审核通过` })
|
||||
|
||||
@@ -2,8 +2,8 @@ import { findPricedOrderRouteAcrossOrigins } from './orders.mjs'
|
||||
|
||||
const MODULE = 'order-inbound-status-fixtures'
|
||||
const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true }
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
|
||||
const ORDER_TARGETS = [
|
||||
['DRAFT', []],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const MODULE = 'orders'
|
||||
const CONTRACT_OPTIONS = { scenario: 'isolated-order-chain', deploymentBlocker: true }
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
const BOXES = [
|
||||
{ boxType: 'SMALL', lengthCm: 30, widthCm: 20, heightCm: 20 },
|
||||
{ boxType: 'MEDIUM', lengthCm: 40, widthCm: 30, heightCm: 30 },
|
||||
@@ -21,8 +21,8 @@ function success(response) { return Boolean(response) && response.http === 200 &
|
||||
function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) }
|
||||
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
|
||||
export function requestedOrderDestinationType(context) {
|
||||
return String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() === 'OVERSEAS'
|
||||
? 'OVERSEAS' : 'FBA'
|
||||
const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase()
|
||||
return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA'
|
||||
}
|
||||
export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) {
|
||||
const summary = orderDetail?.summary
|
||||
@@ -202,35 +202,88 @@ async function assertProductImageDownload(context, merchantToken, fileId) {
|
||||
|
||||
async function loadMasterData(context, merchantToken) {
|
||||
const destinationType = requestedOrderDestinationType(context)
|
||||
const fixedRoute = context.context?.waitSplitRoute || {}
|
||||
const domesticResponse = await request(context, 'GET',
|
||||
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', undefined, merchantToken)
|
||||
const domesticPage = context.apiSuccess(MODULE, 'Client 查询国内收货仓', domesticResponse)
|
||||
context.assert(MODULE, '存在可用国内收货仓', domesticResponse, records(domesticPage).length > 0,
|
||||
`count=${records(domesticPage).length}`)
|
||||
|
||||
const destinationResponse = await request(context, 'GET',
|
||||
`/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`,
|
||||
undefined, merchantToken)
|
||||
const destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse)
|
||||
context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse,
|
||||
records(destinationPage).length > 0, `count=${records(destinationPage).length}`)
|
||||
let destinationResponse = null
|
||||
let destinationPage = null
|
||||
if (destinationType !== 'PRIVATE') {
|
||||
destinationResponse = await request(context, 'GET',
|
||||
`/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`,
|
||||
undefined, merchantToken)
|
||||
destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse)
|
||||
context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse,
|
||||
records(destinationPage).length > 0, `count=${records(destinationPage).length}`)
|
||||
}
|
||||
const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
|
||||
const destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
|
||||
if (receivings.length === 0 || destinations.length === 0) throw new Error('缺少创建测试订单所需的收货仓或目的仓')
|
||||
let destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
|
||||
if (Number(destinationPage?.total) > destinations.length) {
|
||||
const nextResponse = await request(context, 'GET',
|
||||
`/api/merchant/v1/warehouses?page=2&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`,
|
||||
undefined, merchantToken)
|
||||
const nextPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓第二页`, nextResponse)
|
||||
destinations = [...destinations, ...records(nextPage).filter((warehouse) => warehouse?.status === 'ACTIVE')]
|
||||
}
|
||||
if (receivings.length === 0 || (destinationType !== 'PRIVATE' && destinations.length === 0)) {
|
||||
throw new Error('缺少创建测试订单所需的收货仓或目的仓')
|
||||
}
|
||||
|
||||
const pricingResult = await findPricedOrderRouteAcrossOrigins({
|
||||
receivings, destinations,
|
||||
loadChannels: (receiving) => request(context, 'GET',
|
||||
let pricingResult
|
||||
if (fixedRoute.originWarehouseName && (fixedRoute.channelCode || fixedRoute.channelName)) {
|
||||
const receiving = receivings.find((warehouse) => warehouse.warehouseName === fixedRoute.originWarehouseName)
|
||||
if (!receiving) throw new Error(`未找到固定起运仓: ${fixedRoute.originWarehouseName}`)
|
||||
const channelsResponse = await request(context, 'GET',
|
||||
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`,
|
||||
undefined, merchantToken),
|
||||
estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken),
|
||||
destinationType,
|
||||
})
|
||||
undefined, merchantToken)
|
||||
const channels = context.apiSuccess(MODULE, 'Client 查询固定起运仓物流渠道', channelsResponse)
|
||||
const channel = (Array.isArray(channels) ? channels : []).find((item) =>
|
||||
(!fixedRoute.channelCode || item.channelCode === fixedRoute.channelCode)
|
||||
&& (!fixedRoute.channelName || item.channelName === fixedRoute.channelName))
|
||||
if (!channel) throw new Error(`未找到固定物流渠道: ${fixedRoute.channelCode || fixedRoute.channelName}`)
|
||||
const modes = Array.isArray(channel.pricingTaxModes) ? [...channel.pricingTaxModes] : []
|
||||
if (modes.length === 0) throw new Error('固定物流渠道没有可用报价税制')
|
||||
const shift = modes.length ? Number(fixedRoute.taxModeIndex || 0) % modes.length : 0
|
||||
const selectedChannel = { ...channel, pricingTaxModes: [...modes.slice(shift), ...modes.slice(0, shift)] }
|
||||
if (destinationType === 'PRIVATE') {
|
||||
pricingResult = { receiving, priced: { channel: selectedChannel,
|
||||
pricingTaxMode: selectedChannel.pricingTaxModes[0], destination: null },
|
||||
response: channelsResponse, attempts: 0, failures: [], originFailures: [] }
|
||||
} else {
|
||||
const detailResponse = await context.request('GET',
|
||||
`/api/platform/v1/fba-pricing/carrier-rates/price-books/${encodeURIComponent(channel.priceBookVersionId)}`)
|
||||
const detail = context.apiSuccess(MODULE, 'Admin 查询固定渠道当前价格明细', detailResponse)
|
||||
const pricedCodes = new Set((detail?.rates || [])
|
||||
.filter((rate) => rate.channelCode === channel.channelCode && rate.destinationType === destinationType)
|
||||
.map((rate) => String(rate.destinationCode || '').trim()).filter(Boolean))
|
||||
const eligible = destinations.filter((destination) => pricedCodes.has(String(destination.warehouseCode)))
|
||||
if (eligible.length < 1) throw new Error(`固定渠道没有可计价目的仓: ${destinationType}`)
|
||||
const destination = eligible[Number(fixedRoute.destinationIndex || 0) % eligible.length]
|
||||
const result = await findPricedOrderRoute({
|
||||
receiving, destinations: [destination], channels: [selectedChannel],
|
||||
estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken),
|
||||
destinationType,
|
||||
})
|
||||
pricingResult = { receiving, ...result, originFailures: [] }
|
||||
}
|
||||
} else {
|
||||
pricingResult = await findPricedOrderRouteAcrossOrigins({
|
||||
receivings, destinations,
|
||||
loadChannels: (receiving) => request(context, 'GET',
|
||||
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`,
|
||||
undefined, merchantToken),
|
||||
estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken),
|
||||
destinationType,
|
||||
})
|
||||
}
|
||||
const priced = pricingResult.priced
|
||||
const receiving = pricingResult.receiving
|
||||
context.addStep(MODULE, 'Client 从 ACTIVE 国内仓选择具有真实价格覆盖的渠道和目的仓', pricingResult.response, Boolean(priced),
|
||||
priced
|
||||
? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination.warehouseCode}, attempts=${pricingResult.attempts}`
|
||||
? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination?.warehouseCode || 'PRIVATE_ADDRESS'}, attempts=${pricingResult.attempts}`
|
||||
: originPricingFailureEvidence(pricingResult),
|
||||
CONTRACT_OPTIONS)
|
||||
if (!priced) throw new Error(`当前 ACTIVE 国内收货仓、渠道、报价税制和 ${destinationType} 目的仓没有可计价组合`)
|
||||
@@ -249,22 +302,39 @@ function buildPayload(context, profile, master, productImageFileId) {
|
||||
const shipmentId = `SHIP-${context.marker}`.slice(0, 64)
|
||||
const referenceId = `REF-${context.marker}`.slice(0, 64)
|
||||
const companyName = profile.tenantName || `订单验收客户-${context.marker}`
|
||||
const requestedSkuCount = Number(context.context?.waitSplitVariant?.skuCount || BOXES.length)
|
||||
const skuCount = Math.max(1, Math.min(BOXES.length, requestedSkuCount))
|
||||
const packageCountPerSku = Math.max(1,
|
||||
Number(context.context?.waitSplitVariant?.packageCountPerSku
|
||||
|| context.context?.phase3LoadingIncrementalPackageCount || 1))
|
||||
const selectedBoxes = BOXES.slice(0, skuCount)
|
||||
const orderProfile = context.context?.orderProfile || {}
|
||||
const isPrivate = master.destinationType === 'PRIVATE'
|
||||
const pickupMethod = orderProfile.pickupMethod || 'PICKUP'
|
||||
const receiver = isPrivate ? {
|
||||
companyName: `${companyName} 私人收件人`, contactName: `收件人-${context.marker}`.slice(0, 64),
|
||||
phone: '12025550188', email: 'private-receiver@example.com', countryCode: 'US',
|
||||
province: 'New York', city: 'New York', district: 'Queens',
|
||||
addressLine: `${context.marker} 88 Randomized Test Ave`, postalCode: '11354',
|
||||
} : null
|
||||
return {
|
||||
fulfillmentType: String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase(),
|
||||
customerId: master.partner?.id ?? null,
|
||||
customerName: master.partner?.partnerName ?? companyName,
|
||||
customerId: profile.id,
|
||||
customerName: companyName,
|
||||
transportMode: 'SEA', tradeTerm: 'FOB',
|
||||
destinationCountryCode: master.destination.countryCode || 'US',
|
||||
destinationWarehouseId: master.destination.id,
|
||||
destinationWarehouseName: master.destination.warehouseName,
|
||||
destinationCountryCode: master.destination?.countryCode || receiver?.countryCode || 'US',
|
||||
destinationWarehouseId: master.destination?.id || null,
|
||||
destinationWarehouseName: master.destination?.warehouseName || (isPrivate ? '私人地址' : null),
|
||||
expectedShipDate: tomorrow(), customerRemark: `${context.marker} Client 创建隔离订单`,
|
||||
pickupMethod: 'PICKUP', receivingWarehouseId: master.receiving.id,
|
||||
pickupMethod, requestedPickupAt: pickupMethod === 'PICKUP'
|
||||
? new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19) : null,
|
||||
receivingWarehouseId: master.receiving.id,
|
||||
receivingWarehouseName: master.receiving.warehouseName,
|
||||
channelCode: master.channel.channelCode, channelName: master.channel.channelName,
|
||||
carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION',
|
||||
pricingTaxMode: master.pricingTaxMode, containerMode: 'LCL',
|
||||
customsInspection: false, destinationType: master.destinationType,
|
||||
amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination.warehouseCode : null,
|
||||
carrierId: null, carrierName: null, taxMode: orderProfile.taxMode || 'BUY_DECLARATION',
|
||||
pricingTaxMode: master.pricingTaxMode, containerMode: orderProfile.containerMode || 'LCL',
|
||||
customsInspection: orderProfile.customsInspection ?? false, destinationType: master.destinationType,
|
||||
amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination?.warehouseCode : null,
|
||||
shipmentId: master.destinationType === 'FBA' ? shipmentId : null,
|
||||
referenceId: master.destinationType === 'FBA' ? referenceId : null,
|
||||
sender: {
|
||||
@@ -272,11 +342,11 @@ function buildPayload(context, profile, master, productImageFileId) {
|
||||
email: 'order-acceptance@example.com', countryCode: 'CN', province: '广东省',
|
||||
city: '深圳市', district: '南山区', addressLine: `${context.marker} 测试地址`, postalCode: '518000',
|
||||
},
|
||||
receiver: null,
|
||||
cargoes: BOXES.map((box, index) => {
|
||||
receiver,
|
||||
cargoes: selectedBoxes.map((box, index) => {
|
||||
const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000
|
||||
const unitsPerPackage = 10 + index
|
||||
const packageCount = Number(context.context?.phase3LoadingIncrementalPackageCount) === 2 ? 2 : 1
|
||||
const packageCount = packageCountPerSku
|
||||
return {
|
||||
skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
|
||||
cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`,
|
||||
@@ -300,11 +370,12 @@ function buildPayload(context, profile, master, productImageFileId) {
|
||||
}
|
||||
|
||||
function assertCargoContract(context, response, cargoes, payload) {
|
||||
const complete = Array.isArray(cargoes) && cargoes.length === BOXES.length
|
||||
&& BOXES.every((box, index) => {
|
||||
const actual = cargoes[index]; const expected = payload.cargoes[index]
|
||||
return Number(actual?.lengthCm) === box.lengthCm && Number(actual?.widthCm) === box.widthCm
|
||||
&& Number(actual?.heightCm) === box.heightCm && id(actual?.productImageFileId) === id(expected.productImageFileId)
|
||||
const expectedCargoes = payload.cargoes || []
|
||||
const complete = Array.isArray(cargoes) && cargoes.length === expectedCargoes.length
|
||||
&& expectedCargoes.every((expected, index) => {
|
||||
const actual = cargoes[index]
|
||||
return Number(actual?.lengthCm) === expected.lengthCm && Number(actual?.widthCm) === expected.widthCm
|
||||
&& Number(actual?.heightCm) === expected.heightCm && id(actual?.productImageFileId) === id(expected.productImageFileId)
|
||||
&& actual?.skuNo === expected.skuNo && actual?.cargoName === expected.cargoName
|
||||
&& actual?.cargoNameEn === expected.cargoNameEn && actual?.customsHsCode === expected.customsHsCode
|
||||
&& Number(actual?.unitsPerPackage) === expected.unitsPerPackage
|
||||
@@ -320,7 +391,7 @@ function assertCargoContract(context, response, cargoes, payload) {
|
||||
&& (actual?.referenceId ?? null) === (expected.referenceId ?? null)
|
||||
&& actual?.upcNo === expected.upcNo && actual?.cargoRemark.includes(context.marker)
|
||||
})
|
||||
context.assert(MODULE, '四箱型产品字段及图片契约完整回显', response, complete,
|
||||
context.assert(MODULE, '动态SKU产品字段及图片契约完整回显', response, complete,
|
||||
`cargoes=${Array.isArray(cargoes) ? cargoes.length : 'n/a'}, imageFileId=${payload.cargoes[0].productImageFileId}`,
|
||||
CONTRACT_OPTIONS)
|
||||
}
|
||||
@@ -405,9 +476,18 @@ export async function run(context) {
|
||||
})),
|
||||
boxTypes: BOXES.map((box) => box.boxType), inboundId: null,
|
||||
receivingWarehouseId: id(master.receiving.id),
|
||||
receivingWarehouseName: master.receiving.warehouseName,
|
||||
destinationType: master.destinationType,
|
||||
destinationWarehouseId: id(master.destination.id),
|
||||
destinationWarehouseCode: master.destination.warehouseCode,
|
||||
destinationWarehouseId: id(master.destination?.id),
|
||||
destinationWarehouseCode: master.destination?.warehouseCode || null,
|
||||
destinationWarehouseName: master.destination?.warehouseName || (master.destinationType === 'PRIVATE' ? '私人地址' : null),
|
||||
channelCode: master.channel.channelCode,
|
||||
channelName: master.channel.channelName,
|
||||
pricingTaxMode: master.pricingTaxMode,
|
||||
pickupMethod: payload.pickupMethod,
|
||||
taxMode: payload.taxMode,
|
||||
containerMode: payload.containerMode,
|
||||
customsInspection: payload.customsInspection,
|
||||
}
|
||||
|
||||
const replayResponse = await request(context, 'POST', createPath, payload, merchantToken,
|
||||
|
||||
@@ -92,6 +92,12 @@ export async function run(context) {
|
||||
|
||||
const task = matches[0].query
|
||||
const queryId = id(task.id)
|
||||
const automatedCarrier = AUTOMATED_CARRIERS.includes(task?.carrierCode)
|
||||
const manualCarrier = task?.carrierCode === 'OTHER' || !AUTOMATED_CARRIERS.includes(task?.carrierCode)
|
||||
context.assert(MODULE, '出仓自动建任务兼容自动船司与未知船司人工处理', pageResponse,
|
||||
(automatedCarrier && ['READY', 'MANUAL_REQUIRED'].includes(task?.status))
|
||||
|| (manualCarrier && task?.status === 'MANUAL_REQUIRED' && Boolean(task?.manualReason)),
|
||||
`carrierCode=${task?.carrierCode || 'missing'}, status=${task?.status || 'missing'}, manualReason=${task?.manualReason || 'none'}`, OPTIONS)
|
||||
const detailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(queryId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '回查本轮出仓船节点详情', detailResponse, `queryId=${queryId}`, OPTIONS)
|
||||
const departed = (detail?.nodes || []).filter((item) => item.standardNode === 'DEPARTED_WAREHOUSE'
|
||||
@@ -107,6 +113,7 @@ export async function run(context) {
|
||||
queryId, marker: context.marker, ownedByModule: true, triggerSource: 'OUTBOUND_CONFIRM',
|
||||
customerId: fixture.customerId, oceanBookingId: fixture.oceanBookingId,
|
||||
containerBookingId: fixture.containerBookingId, trackingNo: fixture.trackingNo,
|
||||
carrierCode: task?.carrierCode, initialStatus: task?.status,
|
||||
version: Number(data(detailResponse)?.query?.query?.version),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { DOCUMENT_TYPES } from './phase3-container-documents.mjs'
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8')
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('four canonical container documents are frozen and use only public platform APIs', () => {
|
||||
assert.deepEqual(DOCUMENT_TYPES, [
|
||||
'DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER',
|
||||
])
|
||||
for (const token of [
|
||||
'/clearance/document/upload', '/clearance/complete', '/customs/document/upload', '/customs/complete',
|
||||
'/documents/${type}/upload', '/overview', 'requestMultipart', 'Idempotency-Key',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
assert.doesNotMatch(source, /\/internal\/|https?:\/\/|process\.env|\bfetch\s*\(/)
|
||||
})
|
||||
|
||||
test('document lifecycle is fail-closed, traceable and covers replay, hash conflict and zero drift', () => {
|
||||
for (const token of [
|
||||
'finalizeSucceeded !== true', "finalStatus !== 'LOADED'", 'deploymentBlocker: true',
|
||||
'同键同载荷重放', '同键异文件返回A0409', '完成同键重放', '完成同键异载荷返回A0409',
|
||||
'负向请求后持久事实零漂移', 'requestId=', 'traceId=', 'phase3ContainerDocuments',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
})
|
||||
|
||||
test('focused pickup-ready chain orders documents before outbound and candidate visibility', () => {
|
||||
const modules = normalizeModuleOrder(['phase4-overseas-pda'])
|
||||
const required = [
|
||||
'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs',
|
||||
'phase3-outbound-dispatch', 'phase3-carrier-schedule-queries',
|
||||
'phase4-carrier-pickup-ready', 'phase4-overseas-pda',
|
||||
]
|
||||
for (const module of required) assert.ok(modules.includes(module), module)
|
||||
for (let index = 1; index < required.length; index += 1) {
|
||||
assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index])
|
||||
}
|
||||
assert.match(entry, /phase3-container-documents/)
|
||||
assert.match(entry, /phase3-container-documents-phase4-/)
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
const MODULE = 'phase3-container-documents'
|
||||
const OPTIONS = { scenario: 'phase3-container-four-documents', deploymentBlocker: true }
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
const FIRST_LEG = '/api/platform/v1/first-leg'
|
||||
|
||||
export const DOCUMENT_TYPES = Object.freeze([
|
||||
'DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER',
|
||||
])
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
|
||||
function conflict(response) { return response?.http === 200 && codeOf(response) === 'A0409' }
|
||||
function traced(response) { return Boolean(response?.requestId) && Boolean(response?.traceId) }
|
||||
function key(context, label) { return `${context.marker}-four-documents-${label}`.slice(0, 128) }
|
||||
function validDocument(document, type, completed) {
|
||||
return Boolean(document?.id) && Boolean(document?.fileId) && document?.documentType === type
|
||||
&& /^[0-9a-f]{64}$/i.test(String(document?.fileHash || ''))
|
||||
&& Number.isInteger(Number(document?.version))
|
||||
&& (completed ? Boolean(document?.completedAt) : document?.completedAt == null)
|
||||
}
|
||||
|
||||
function documentForm(context, type, expectedVersion, operationReason, altered = false) {
|
||||
const form = new FormData()
|
||||
const suffix = altered ? '-altered' : ''
|
||||
form.append('file', new Blob([`${context.marker}-${type}${suffix}`], { type: 'text/plain' }),
|
||||
`${type.toLowerCase()}${suffix}.txt`)
|
||||
form.append('expectedVersion', String(expectedVersion))
|
||||
form.append('operationReason', operationReason)
|
||||
return form
|
||||
}
|
||||
|
||||
async function upload(context, path, type, expectedVersion, operationReason, idempotencyKey, altered = false) {
|
||||
return context.requestMultipart('POST', path,
|
||||
documentForm(context, type, expectedVersion, operationReason, altered), context.token,
|
||||
{ 'Idempotency-Key': idempotencyKey })
|
||||
}
|
||||
|
||||
async function uploadAndVerify(context, { type, uploadPath, readPath, completePath = null }) {
|
||||
const operationReason = `${context.marker} 四类货柜资料全链验收`
|
||||
const uploadKey = key(context, `${type}-upload`)
|
||||
const response = await upload(context, uploadPath, type, 0, operationReason, uploadKey)
|
||||
let document = context.apiSuccess(MODULE, `上传${type}`, response, `documentType=${type}`, OPTIONS)
|
||||
context.assert(MODULE, `${type}上传返回归属、文件哈希和草稿版本`, response,
|
||||
validDocument(document, type, false) && traced(response),
|
||||
`documentId=${id(document?.id) || 'missing'}, fileId=${id(document?.fileId) || 'missing'}, version=${document?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!validDocument(document, type, false)) return null
|
||||
|
||||
const replay = await upload(context, uploadPath, type, 0, operationReason, uploadKey)
|
||||
const replayed = context.apiSuccess(MODULE, `${type}同键同载荷重放`, replay, '', OPTIONS)
|
||||
context.assert(MODULE, `${type}上传重放不新增资料或递增版本`, replay,
|
||||
id(replayed?.id) === id(document.id) && id(replayed?.fileId) === id(document.fileId)
|
||||
&& Number(replayed?.version) === Number(document.version),
|
||||
`documentId=${id(replayed?.id) || 'missing'}, version=${replayed?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const hashConflict = await upload(context, uploadPath, type, 0, operationReason, uploadKey, true)
|
||||
context.addStep(MODULE, `${type}同键异文件返回A0409`, hashConflict,
|
||||
conflict(hashConflict) && traced(hashConflict),
|
||||
`HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}, requestId=${hashConflict.requestId || 'missing'}, traceId=${hashConflict.traceId || 'missing'}`, OPTIONS)
|
||||
|
||||
if (completePath) {
|
||||
const completeKey = key(context, `${type}-complete`)
|
||||
const completeBody = { expectedVersion: Number(document.version), operationReason }
|
||||
const completeResponse = await context.request('POST', completePath, completeBody, context.token,
|
||||
{ 'Idempotency-Key': completeKey })
|
||||
document = context.apiSuccess(MODULE, `完成${type}`, completeResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${type}完成后锁定且版本递增`, completeResponse,
|
||||
validDocument(document, type, true) && Number(document.version) === Number(completeBody.expectedVersion) + 1,
|
||||
`completedAt=${document?.completedAt || 'missing'}, version=${document?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const completeReplay = await context.request('POST', completePath, completeBody, context.token,
|
||||
{ 'Idempotency-Key': completeKey })
|
||||
const replayedComplete = context.apiSuccess(MODULE, `${type}完成同键重放`, completeReplay, '', OPTIONS)
|
||||
context.assert(MODULE, `${type}完成重放保持同一终态`, completeReplay,
|
||||
id(replayedComplete?.id) === id(document.id)
|
||||
&& Number(replayedComplete?.version) === Number(document.version)
|
||||
&& replayedComplete?.completedAt === document.completedAt,
|
||||
`documentId=${id(replayedComplete?.id) || 'missing'}, version=${replayedComplete?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const completeConflict = await context.request('POST', completePath,
|
||||
{ ...completeBody, operationReason: `${operationReason}-altered` }, context.token,
|
||||
{ 'Idempotency-Key': completeKey })
|
||||
context.addStep(MODULE, `${type}完成同键异载荷返回A0409`, completeConflict,
|
||||
conflict(completeConflict) && traced(completeConflict),
|
||||
`HTTP=${completeConflict.http}, code=${codeOf(completeConflict)}`, OPTIONS)
|
||||
}
|
||||
|
||||
const readResponse = await context.request('GET', readPath)
|
||||
const stored = context.apiSuccess(MODULE, `回读${type}`, readResponse, '', OPTIONS)
|
||||
const expectedCompleted = Boolean(completePath)
|
||||
context.assert(MODULE, `${type}负向请求后持久事实零漂移`, readResponse,
|
||||
validDocument(stored, type, expectedCompleted) && id(stored.id) === id(document.id)
|
||||
&& id(stored.fileId) === id(document.fileId) && Number(stored.version) === Number(document.version),
|
||||
`documentId=${id(stored?.id) || 'missing'}, version=${stored?.version ?? 'missing'}`, OPTIONS)
|
||||
return stored
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.phase3Loading
|
||||
const containerId = id(fixture?.containerBookingId)
|
||||
const planId = id(fixture?.loadingPlanId)
|
||||
const warehouseId = id(fixture?.warehouseId)
|
||||
if (fixture?.marker !== context.marker || fixture?.finalizeSucceeded !== true
|
||||
|| fixture?.finalStatus !== 'LOADED' || !containerId || !planId || !warehouseId) {
|
||||
context.addStep(MODULE, '取得本轮LOADED柜次四类资料前置', null, false,
|
||||
`marker=${fixture?.marker || 'missing'}, status=${fixture?.finalStatus || 'missing'}, containerBookingId=${containerId || 'missing'}, loadingPlanId=${planId || 'missing'}, warehouseId=${warehouseId || 'missing'}`,
|
||||
OPTIONS)
|
||||
return
|
||||
}
|
||||
|
||||
const specs = [
|
||||
{
|
||||
type: 'DESTINATION_CLEARANCE',
|
||||
uploadPath: `${CONTAINERS}/${containerId}/clearance/document/upload`,
|
||||
readPath: `${CONTAINERS}/${containerId}/clearance`,
|
||||
completePath: `${CONTAINERS}/${containerId}/clearance/complete`,
|
||||
},
|
||||
{
|
||||
type: 'CUSTOMS_DECLARATION',
|
||||
uploadPath: `${FIRST_LEG}/loading-lists/${planId}/customs/document/upload?warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
readPath: `${FIRST_LEG}/loading-lists/${planId}/customs?warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
completePath: `${FIRST_LEG}/loading-lists/${planId}/customs/complete?warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
},
|
||||
...['DELIVERY_ORDER', 'PICKUP_ORDER'].map((type) => ({
|
||||
type,
|
||||
uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`,
|
||||
readPath: `${CONTAINERS}/${containerId}/documents/${type}`,
|
||||
})),
|
||||
]
|
||||
|
||||
const documents = []
|
||||
for (const spec of specs) documents.push(await uploadAndVerify(context, spec))
|
||||
const overviewResponse = await context.request('GET', `${CONTAINERS}/${containerId}/overview`)
|
||||
const overview = context.apiSuccess(MODULE, '回查货柜四类资料聚合', overviewResponse, '', OPTIONS)
|
||||
const storedTypes = new Set((overview?.documents || []).map((item) => item?.documentType))
|
||||
context.assert(MODULE, '同一LOADED柜次聚合返回四类资料且身份未漂移', overviewResponse,
|
||||
id(overview?.container?.id) === containerId && documents.every(Boolean)
|
||||
&& DOCUMENT_TYPES.every((type) => storedTypes.has(type)),
|
||||
`containerBookingId=${containerId}, documentTypes=${[...storedTypes].join('|') || 'none'}`, OPTIONS)
|
||||
if (documents.every(Boolean) && DOCUMENT_TYPES.every((type) => storedTypes.has(type))) {
|
||||
context.createdResources.phase3ContainerDocuments = {
|
||||
marker: context.marker, containerBookingId: containerId, loadingPlanId: planId,
|
||||
warehouseId, finalStatus: 'COMPLETED', documentTypes: [...DOCUMENT_TYPES],
|
||||
documentIds: Object.fromEntries(documents.map((item) => [item.documentType, id(item.id)])),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ const MODULE = 'phase3-customs'
|
||||
const OPTIONS = { scenario: 'phase3-customs-collaboration', deploymentBlocker: true }
|
||||
const PLATFORM = '/api/platform/v1/customs'
|
||||
const MERCHANT = '/api/merchant/v1/customs'
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
@@ -113,6 +113,99 @@ async function ensureReleasedOceanBooking(context, container) {
|
||||
return true
|
||||
}
|
||||
|
||||
async function uploadVersion(context, path, label, expectedVersion, operation, content) {
|
||||
const response = await upload(context, path, {
|
||||
expectedVersion, operationReason: `${context.marker}-${operation}`,
|
||||
}, content, `${context.marker}-${operation}.txt`, context.token, key(context, operation))
|
||||
const document = context.apiSuccess(MODULE, label, response, '', OPTIONS)
|
||||
context.assert(MODULE, `${label}返回当前唯一文件事实`, response,
|
||||
Boolean(document?.id) && Boolean(document?.fileId) && Number.isInteger(Number(document?.version))
|
||||
&& /^[0-9a-f]{64}$/i.test(String(document?.fileHash || '')),
|
||||
`documentId=${id(document?.id) || 'missing'}, fileId=${id(document?.fileId) || 'missing'}, version=${document?.version ?? 'missing'}`, OPTIONS)
|
||||
return { response, document }
|
||||
}
|
||||
|
||||
async function verifyFourContainerDocuments(context, { containerBookingId, loadingPlanId, warehouseId }) {
|
||||
const reason = `${context.marker}-人工验收节点资料`
|
||||
const simpleDocuments = [
|
||||
{ type: 'DESTINATION_CLEARANCE', base: `/api/platform/v1/containers/${containerBookingId}/clearance`,
|
||||
uploadPath: `/api/platform/v1/containers/${containerBookingId}/clearance/document/upload` },
|
||||
{ type: 'CUSTOMS_DECLARATION', base: `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs`,
|
||||
query: `warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
uploadPath: `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs/document/upload?warehouseId=${encodeURIComponent(warehouseId)}` },
|
||||
]
|
||||
const completed = []
|
||||
for (const document of simpleDocuments) {
|
||||
const first = await uploadVersion(context, document.uploadPath, `首次上传${document.type}`, 0,
|
||||
`${document.type.toLowerCase()}-v1`, `${context.marker} ${document.type} v1`)
|
||||
if (!first.document?.fileId) return false
|
||||
const readPath = `${document.base}${document.query ? `?${document.query}` : ''}`
|
||||
const firstRead = await context.request('GET', readPath)
|
||||
const firstEcho = context.apiSuccess(MODULE, `回显${document.type}首版`, firstRead, '', OPTIONS)
|
||||
context.assert(MODULE, `${document.type}首版回显身份一致`, firstRead,
|
||||
id(firstEcho?.id) === id(first.document.id) && id(firstEcho?.fileId) === id(first.document.fileId),
|
||||
`documentId=${id(firstEcho?.id) || 'missing'}, version=${firstEcho?.version ?? 'missing'}`, OPTIONS)
|
||||
const firstDownloadPath = document.type === 'DESTINATION_CLEARANCE'
|
||||
? `${document.base}/document/${id(first.document.fileId)}/download`
|
||||
: `${document.base}/document/${id(first.document.fileId)}/download?${document.query}`
|
||||
const firstDownload = await download(context, firstDownloadPath, context.token)
|
||||
context.assert(MODULE, `下载${document.type}首版`, firstDownload,
|
||||
firstDownload.http === 200 && firstDownload.bytes.length > 0 && firstDownload.contentType.includes('text/plain'),
|
||||
`HTTP=${firstDownload.http}, bytes=${firstDownload.bytes.length}`, OPTIONS)
|
||||
const second = await uploadVersion(context, document.uploadPath, `更新${document.type}为第二版`,
|
||||
Number(firstEcho?.version), `${document.type.toLowerCase()}-v2`, `${context.marker} ${document.type} v2`)
|
||||
context.assert(MODULE, `${document.type}更新保持同一聚合并递增版本`, second.response,
|
||||
id(second.document?.id) === id(first.document.id) && Number(second.document?.version) === Number(firstEcho?.version) + 1,
|
||||
`documentId=${id(second.document?.id) || 'missing'}, version=${second.document?.version ?? 'missing'}`, OPTIONS)
|
||||
const completeResponse = await request(context, 'POST', `${document.base}/complete${document.query ? `?${document.query}` : ''}`, {
|
||||
expectedVersion: Number(second.document?.version), operationReason: reason,
|
||||
}, context.token, { 'Idempotency-Key': key(context, `${document.type.toLowerCase()}-complete`) })
|
||||
const complete = context.apiSuccess(MODULE, `完成${document.type}`, completeResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${document.type}完成态、时间和版本可回读`, completeResponse,
|
||||
id(complete?.id) === id(first.document.id) && Boolean(complete?.completedAt)
|
||||
&& Number(complete?.version) === Number(second.document?.version) + 1,
|
||||
`completedAt=${complete?.completedAt || 'missing'}, version=${complete?.version ?? 'missing'}`, OPTIONS)
|
||||
completed.push(complete)
|
||||
}
|
||||
|
||||
for (const type of ['DELIVERY_ORDER', 'PICKUP_ORDER']) {
|
||||
const base = `/api/platform/v1/containers/${containerBookingId}/documents/${type}`
|
||||
const first = await uploadVersion(context, `${base}/upload`, `首次上传${type}`, 0,
|
||||
`${type.toLowerCase()}-v1`, `${context.marker} ${type} v1`)
|
||||
if (!first.document?.fileId) return false
|
||||
const firstDownload = await download(context, `${base}/${id(first.document.fileId)}/download`, context.token)
|
||||
context.assert(MODULE, `下载${type}首版`, firstDownload,
|
||||
firstDownload.http === 200 && firstDownload.bytes.length > 0 && firstDownload.contentType.includes('text/plain'),
|
||||
`HTTP=${firstDownload.http}, bytes=${firstDownload.bytes.length}`, OPTIONS)
|
||||
const second = await uploadVersion(context, `${base}/upload`, `更新${type}为第二版`,
|
||||
Number(first.document.version), `${type.toLowerCase()}-v2`, `${context.marker} ${type} v2`)
|
||||
const echoResponse = await context.request('GET', base)
|
||||
const echo = context.apiSuccess(MODULE, `回显${type}当前版本`, echoResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${type}首次与更新保持同一聚合且当前版本唯一`, echoResponse,
|
||||
id(echo?.id) === id(first.document.id) && id(echo?.fileId) === id(second.document?.fileId)
|
||||
&& Number(echo?.version) === Number(first.document.version) + 1,
|
||||
`documentId=${id(echo?.id) || 'missing'}, version=${echo?.version ?? 'missing'}`, OPTIONS)
|
||||
}
|
||||
|
||||
const overviewResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}/overview`)
|
||||
const overview = context.apiSuccess(MODULE, '四类资料完成后回查整柜聚合', overviewResponse, '', OPTIONS)
|
||||
const documents = Array.isArray(overview?.documents) ? overview.documents : []
|
||||
const photos = Array.isArray(overview?.pda?.photos) ? overview.pda.photos.filter((item) => item?.status === 'ACTIVE') : []
|
||||
context.assert(MODULE, '整柜聚合回显四类当前资料与真实PDA照片证据', overviewResponse,
|
||||
['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER']
|
||||
.every((type) => documents.some((item) => item?.documentType === type))
|
||||
&& completed.every((item) => documents.some((value) => id(value?.id) === id(item?.id) && value?.completed === true))
|
||||
&& photos.length >= 5 && photos.every((item) => /^\/api\/platform\/v1\/containers\/\d+\/pda\/photos\/\d+\/download$/.test(String(item?.downloadPath || ''))),
|
||||
`documents=${documents.map((item) => item?.documentType).join('|') || 'none'}, activePhotos=${photos.length}`, OPTIONS)
|
||||
for (const [index, photo] of photos.entries()) {
|
||||
const response = await download(context, photo.downloadPath, context.token)
|
||||
context.assert(MODULE, `下载整柜PDA照片证据${index + 1}`, response,
|
||||
response.http === 200 && response.bytes.length > 0 && !response.contentType.includes('application/json'),
|
||||
`fileId=${id(photo?.fileId) || 'missing'}, bytes=${response.bytes.length}`, OPTIONS)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const { fixture, container } = selectCustomsFlow(context.createdResources)
|
||||
const tenantId = id(fixture?.tenantId)
|
||||
@@ -120,7 +213,8 @@ export async function run(context) {
|
||||
const inboundId = id(fixture?.inboundId)
|
||||
const containerBookingId = id(container?.containerBookingId)
|
||||
const loadingPlanId = id(container?.loadingPlanId)
|
||||
if (!tenantId || !parentOrderId || !inboundId || !containerBookingId
|
||||
const warehouseId = id(container?.warehouseId || fixture?.warehouseId)
|
||||
if (!tenantId || !parentOrderId || !inboundId || !containerBookingId || !loadingPlanId || !warehouseId
|
||||
|| !['AWAITING_REVIEW', 'LOADED'].includes(container?.finalStatus)
|
||||
|| (container?.finalStatus === 'AWAITING_REVIEW'
|
||||
&& (container?.loadingPlanStatus !== 'LOADING' || !container?.canonicalPdaTaskId))) {
|
||||
@@ -380,6 +474,12 @@ export async function run(context) {
|
||||
context.addStep(MODULE, '回执 fileId 不能伪造为客户报关资料引用', forgedReference, failure(forgedReference),
|
||||
`HTTP=${forgedReference.http}, code=${codeOf(forgedReference)}`, OPTIONS)
|
||||
|
||||
if (!(await verifyFourContainerDocuments(context, { containerBookingId, loadingPlanId, warehouseId }))) {
|
||||
context.addStep(MODULE, '四类整柜资料必须完成后才允许进入出仓确认', null, false,
|
||||
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}, warehouseId=${warehouseId}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
|
||||
context.createdResources.phase3Customs = {
|
||||
marker: context.marker, tenantId, parentOrderId, orderId: actualOrderId, inboundId, containerBookingId, loadingPlanId,
|
||||
customsCaseId: caseId, submissionBatchId: batchId, releasedReceiptId: releasedId,
|
||||
|
||||
@@ -12,15 +12,16 @@ export async function run(context) {
|
||||
'本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture')
|
||||
return
|
||||
}
|
||||
const selector = `inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(fixture.containerBookingId)}`
|
||||
const response = await context.request('GET', `${BASE}/check-gate?${selector}`)
|
||||
const gate = context.apiSuccess(MODULE, '复核国内出仓统一硬门禁', response, selector, OPTIONS)
|
||||
const items = Array.isArray(gate?.items) ? gate.items : []
|
||||
const response = await context.request('GET', `${BASE}/${encodeURIComponent(fixture.containerBookingId)}/gate`)
|
||||
const gate = context.apiSuccess(MODULE, '复核国内出仓柜级统一硬门禁', response,
|
||||
`containerBookingId=${fixture.containerBookingId}`, OPTIONS)
|
||||
const items = Array.isArray(gate?.inbounds)
|
||||
? gate.inbounds.flatMap((item) => Array.isArray(item?.gate?.items) ? item.gate.items : []) : []
|
||||
const required = ['INBOUND_LOADED', 'LOADING_PLAN_COMPLETED', 'PDA_PICK_COMPLETED',
|
||||
'OCEAN_BOOKING_RELEASED', 'TRUCK_DEPARTED', 'LOADING_EVIDENCE_COMPLETE',
|
||||
'CUSTOMS_CLEARANCE', 'SAILING_SCHEDULE']
|
||||
context.assert(MODULE, '八项业务门禁全部由服务端真实判定并通过', response,
|
||||
'OCEAN_BOOKING_RELEASED', 'LOADING_EVIDENCE_COMPLETE', 'CUSTOMS_CLEARANCE']
|
||||
context.assert(MODULE, '六项硬门禁全部由服务端真实判定并通过', response,
|
||||
gate?.passed === true && required.every((code) => items.some((item) => item.code === code
|
||||
&& item.passed === true && item.actual !== 'MODULE_UNAVAILABLE' && item.actual !== 'ASSESSMENT_ERROR')),
|
||||
&& item.passed === true && item.actual !== 'MODULE_UNAVAILABLE' && item.actual !== 'ASSESSMENT_ERROR'))
|
||||
&& items.every((item) => !['TRUCK_DEPARTED', 'SAILING_SCHEDULE'].includes(item.code)),
|
||||
`passed=${gate?.passed ?? 'missing'}, gates=${items.map((item) => `${item.code}:${item.passed}`).join(',')}`, OPTIONS)
|
||||
}
|
||||
|
||||
@@ -32,8 +32,12 @@ test('phase3 inventory mutation and downstream gates cannot pass as coverage gap
|
||||
const aggregate = read('phase3-outbound-aggregate.mjs')
|
||||
assert.match(loading, /自动制造库存变化并验证旧 snapshot token A0409[\s\S]{0,300}false/)
|
||||
assert.match(convergence, /PDA 真实库存变化后旧 token A0409 与刷新重试[\s\S]{0,300}false/)
|
||||
assert.match(dispatch, /国内出仓八项门禁必须在同轮全部通过[\s\S]{0,300}false/)
|
||||
assert.match(aggregate, /'CUSTOMS', 'SAILING_SCHEDULE'/)
|
||||
assert.match(dispatch, /国内出仓六项硬门禁必须在同轮全部通过[\s\S]{0,300}false/)
|
||||
assert.match(dispatch, /拖车与独立船期仅作聚合参考且不参与出仓门禁/)
|
||||
assert.match(dispatch, /'CUSTOMS_CLEARANCE'/)
|
||||
assert.match(dispatch, /const expectedCodes = \['INBOUND_LOADED', 'LOADING_PLAN_COMPLETED', 'PDA_PICK_COMPLETED',[\s\S]{0,120}'CUSTOMS_CLEARANCE'\]/)
|
||||
assert.match(aggregate, /'OCEAN_BOOKING', 'CUSTOMS', 'OUTBOUND_DISPATCH'/)
|
||||
assert.match(aggregate, /拖车和独立船期时间线只作参考/)
|
||||
assert.doesNotMatch(aggregate, /报关与船期历史并入统一时间线|当前服务端聚合未公开/)
|
||||
})
|
||||
|
||||
@@ -59,4 +63,5 @@ test('outbound lifecycle checks confirm replay, immutable departure reverse boun
|
||||
assert.match(waiting, /confirmations\.length === 1 && reversed\.length === 0/)
|
||||
assert.match(waiting, /实际离仓节点冻结后保持一次确认且零次成功撤销/)
|
||||
assert.match(waiting, /detail\?\.aggregate\?\.dispatchStatus === 'WAITING_FOR_LOADING'/)
|
||||
assert.match(waiting, /inbounds\?\.every/)
|
||||
})
|
||||
|
||||
@@ -3,8 +3,8 @@ import { createHash } from 'node:crypto'
|
||||
|
||||
const MODULE = 'phase3-loading'
|
||||
const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true }
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const MODULE = 'phase3-order-splitting'
|
||||
const SCENARIO = 'source-loaded-single-remainder-chain'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
||||
const PDA = '/api/platform/v1/first-leg/pda-loading'
|
||||
|
||||
@@ -23,29 +23,27 @@ export async function run(context) {
|
||||
'本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture')
|
||||
return
|
||||
}
|
||||
const selector = `inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(fixture.containerBookingId)}`
|
||||
const response = await context.request('GET', `${BASE}/detail?${selector}`)
|
||||
const detail = context.apiSuccess(MODULE, '复核出仓主聚合与统一时间线', response, selector, OPTIONS)
|
||||
const response = await context.request('GET', `${BASE}/${encodeURIComponent(fixture.containerBookingId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '复核柜级出仓主聚合与统一时间线', response,
|
||||
`containerBookingId=${fixture.containerBookingId}`, OPTIONS)
|
||||
const aggregate = detail?.aggregate
|
||||
const inbound = aggregate?.inbounds?.find?.((item) => id(item?.inboundId) === id(fixture.inboundId))
|
||||
const timeline = Array.isArray(detail?.timeline) ? detail.timeline : []
|
||||
const expectedModules = ['INBOUND', 'LOADING_PLAN', 'PDA_PICK', 'LOADING_EVIDENCE',
|
||||
'OCEAN_BOOKING', 'TRUCK_APPOINTMENT', 'CUSTOMS', 'SAILING_SCHEDULE', 'OUTBOUND_DISPATCH']
|
||||
'OCEAN_BOOKING', 'CUSTOMS', 'OUTBOUND_DISPATCH']
|
||||
context.assert(MODULE, '聚合唯一关联本轮订单、入库单、柜次和客户', response,
|
||||
id(aggregate?.orderId) === id(fixture.orderId) && id(aggregate?.tenantId) === id(fixture.tenantId)
|
||||
&& id(aggregate?.inboundId) === id(fixture.inboundId)
|
||||
id(inbound?.orderId) === id(fixture.orderId) && id(inbound?.tenantId) === id(fixture.tenantId)
|
||||
&& id(inbound?.inboundId) === id(fixture.inboundId)
|
||||
&& id(aggregate?.containerBookingId) === id(fixture.containerBookingId),
|
||||
`orderId=${id(aggregate?.orderId) || 'missing'}, inboundId=${id(aggregate?.inboundId) || 'missing'}, containerBookingId=${id(aggregate?.containerBookingId) || 'missing'}`, OPTIONS)
|
||||
`orderId=${id(inbound?.orderId) || 'missing'}, inboundId=${id(inbound?.inboundId) || 'missing'}, containerBookingId=${id(aggregate?.containerBookingId) || 'missing'}`, OPTIONS)
|
||||
context.assert(MODULE, '统一时间线有序且各子流程包含审计字段', response,
|
||||
timeline.length > 0 && ordered(timeline)
|
||||
&& expectedModules.every((module) => timeline.some((item) => item.module === module))
|
||||
&& timeline.every((item) => Boolean(item.action) && Boolean(item.occurredAt)),
|
||||
`timeline=${timeline.length}, modules=${[...new Set(timeline.map((item) => item.module))].join(',')}`, OPTIONS)
|
||||
|
||||
const other = context.createdResources?.phase3ContainerLoading
|
||||
if (other?.inboundId && other?.containerBookingId
|
||||
&& (id(other.inboundId) !== id(fixture.inboundId) || id(other.containerBookingId) !== id(fixture.containerBookingId))) {
|
||||
const forged = await context.request('GET', `${BASE}/detail?inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(other.containerBookingId)}`)
|
||||
context.addStep(MODULE, '伪造跨资源入库单与柜次组合不可匹配', forged, rejected(forged),
|
||||
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
|
||||
}
|
||||
context.assert(MODULE, '拖车和独立船期时间线只作参考', response,
|
||||
['TRUCK_APPOINTMENT', 'SAILING_SCHEDULE'].every((module) => timeline
|
||||
.filter((item) => item.module === module).every((item) => Boolean(item.action) && Boolean(item.occurredAt))),
|
||||
`referenceModules=${timeline.filter((item) => ['TRUCK_APPOINTMENT', 'SAILING_SCHEDULE'].includes(item.module)).length}`, OPTIONS)
|
||||
}
|
||||
|
||||
@@ -43,52 +43,50 @@ export async function run(context) {
|
||||
'本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture')
|
||||
return
|
||||
}
|
||||
const selector = `inboundId=${encodeURIComponent(inboundId)}&containerBookingId=${encodeURIComponent(containerBookingId)}`
|
||||
const selector = `containerBookingId=${encodeURIComponent(containerBookingId)}`
|
||||
const listResponse = await request(context, 'GET', `${BASE}?${selector}`)
|
||||
const aggregates = context.apiSuccess(MODULE, '按入库单和柜次查询出仓主聚合', listResponse, selector, OPTIONS)
|
||||
const aggregate = Array.isArray(aggregates) ? aggregates.find((item) => id(item.inboundId) === inboundId
|
||||
&& id(item.containerBookingId) === containerBookingId) : null
|
||||
const aggregates = context.apiSuccess(MODULE, '按柜次查询唯一出仓主聚合', listResponse, selector, OPTIONS)
|
||||
const aggregate = Array.isArray(aggregates) ? aggregates.find((item) => id(item.containerBookingId) === containerBookingId) : null
|
||||
const inboundBinding = aggregate?.inbounds?.find?.((item) => id(item?.inboundId) === inboundId)
|
||||
const outboundFieldGaps = outboundAggregateFieldGaps(aggregate)
|
||||
context.assert(MODULE, '主聚合串联订单、入库单、柜次、计划与客户隔离键', listResponse,
|
||||
Boolean(aggregate?.orderId) && Boolean(aggregate?.tenantId) && Boolean(aggregate?.loadingPlanId)
|
||||
&& id(aggregate?.inboundId) === inboundId && id(aggregate?.containerBookingId) === containerBookingId,
|
||||
`orderId=${id(aggregate?.orderId) || 'missing'}, tenantId=${id(aggregate?.tenantId) || 'missing'}, planId=${id(aggregate?.loadingPlanId) || 'missing'}`, OPTIONS)
|
||||
Boolean(inboundBinding?.orderId) && Boolean(inboundBinding?.tenantId) && Boolean(aggregate?.loadingPlanId)
|
||||
&& id(aggregate?.containerBookingId) === containerBookingId,
|
||||
`orderId=${id(inboundBinding?.orderId) || 'missing'}, tenantId=${id(inboundBinding?.tenantId) || 'missing'}, planId=${id(aggregate?.loadingPlanId) || 'missing'}, inbounds=${aggregate?.inbounds?.length ?? 'missing'}`, OPTIONS)
|
||||
context.assert(MODULE, '国内出仓列表客户、订单入库、柜次计划、PDA订舱与门禁摘要完整', listResponse,
|
||||
outboundFieldGaps.length === 0, `missing=${outboundFieldGaps.join(',') || 'none'}`, OPTIONS)
|
||||
if (!aggregate) return
|
||||
|
||||
const orderResponse = await request(context, 'GET', `${BASE}?orderId=${encodeURIComponent(id(aggregate.orderId))}`)
|
||||
const orderAggregates = context.apiSuccess(MODULE, '按 orderId 查询全部出仓柜次', orderResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'orderId 查询包含本轮入库单柜次', orderResponse,
|
||||
Array.isArray(orderAggregates) && orderAggregates.some((item) => id(item.inboundId) === inboundId
|
||||
&& id(item.containerBookingId) === containerBookingId), `records=${orderAggregates?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const detailResponse = await request(context, 'GET', `${BASE}/detail?${selector}`)
|
||||
const detailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(containerBookingId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '查询出仓主聚合与整体时间线', detailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '整体时间线返回稳定数组且聚合定位不漂移', detailResponse,
|
||||
Array.isArray(detail?.timeline) && id(detail?.aggregate?.inboundId) === inboundId
|
||||
&& id(detail?.aggregate?.containerBookingId) === containerBookingId,
|
||||
Array.isArray(detail?.timeline) && id(detail?.aggregate?.containerBookingId) === containerBookingId
|
||||
&& detail?.aggregate?.inbounds?.some?.((item) => id(item?.inboundId) === inboundId),
|
||||
`timeline=${detail?.timeline?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const gateResponse = await request(context, 'GET', `${BASE}/check-gate?${selector}`)
|
||||
const gateResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(containerBookingId)}/gate`)
|
||||
const gate = context.apiSuccess(MODULE, '执行国内出仓统一门禁', gateResponse, '', OPTIONS)
|
||||
const gateItems = Array.isArray(gate?.items) ? gate.items : []
|
||||
const gateItems = Array.isArray(gate?.inbounds)
|
||||
? gate.inbounds.flatMap((item) => Array.isArray(item?.gate?.items) ? item.gate.items : []) : []
|
||||
const expectedCodes = ['INBOUND_LOADED', 'LOADING_PLAN_COMPLETED', 'PDA_PICK_COMPLETED',
|
||||
'OCEAN_BOOKING_RELEASED', 'TRUCK_DEPARTED', 'LOADING_EVIDENCE_COMPLETE',
|
||||
'CUSTOMS_CLEARANCE', 'SAILING_SCHEDULE']
|
||||
'OCEAN_BOOKING_RELEASED', 'LOADING_EVIDENCE_COMPLETE', 'CUSTOMS_CLEARANCE']
|
||||
context.assert(MODULE, '门禁逐项公开真实状态且不省略下游模块', gateResponse,
|
||||
expectedCodes.every((code) => gateItems.some((item) => item.code === code && typeof item.passed === 'boolean')),
|
||||
expectedCodes.every((code) => gateItems.some((item) => item.code === code && typeof item.passed === 'boolean'))
|
||||
&& gateItems.every((item) => !['TRUCK_DEPARTED', 'SAILING_SCHEDULE'].includes(item.code)),
|
||||
`codes=${gateItems.map((item) => item.code).join(',')}`, OPTIONS)
|
||||
context.assert(MODULE, '拖车与独立船期仅作聚合参考且不参与出仓门禁', gateResponse,
|
||||
Object.hasOwn(aggregate, 'truckAppointmentStatus') && Object.hasOwn(aggregate, 'oceanBookingStatus')
|
||||
&& gateItems.every((item) => !['TRUCK_DEPARTED', 'SAILING_SCHEDULE'].includes(item.code)),
|
||||
`truck=${aggregate?.truckAppointmentStatus || 'none'}, ocean=${aggregate?.oceanBookingStatus || 'none'}`, OPTIONS)
|
||||
|
||||
const expectedVersion = Number(aggregate.version)
|
||||
const actionBody = { inboundId, containerBookingId, expectedVersion, remark: context.marker }
|
||||
const confirmKey = `${context.marker}-outbound-confirm`.slice(0, 128)
|
||||
if (!gate?.passed) {
|
||||
const blocked = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody,
|
||||
const blocked = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, { expectedVersion, remark: context.marker },
|
||||
{ 'Idempotency-Key': confirmKey })
|
||||
context.addStep(MODULE, '门禁未通过时 confirm-dispatch fail-closed', blocked, rejected(blocked),
|
||||
`HTTP=${blocked.http}, code=${codeOf(blocked)}`, OPTIONS)
|
||||
context.addStep(MODULE, '国内出仓八项门禁必须在同轮全部通过', gateResponse, false,
|
||||
context.addStep(MODULE, '国内出仓六项硬门禁必须在同轮全部通过', gateResponse, false,
|
||||
`failed=${gateItems.filter((item) => !item.passed).map((item) => `${item.code}:${item.actual || 'missing'}`).join(',') || 'gate.passed=false'}`,
|
||||
OPTIONS)
|
||||
for (const item of gateItems.filter((value) => !value.passed)) {
|
||||
@@ -99,17 +97,18 @@ export async function run(context) {
|
||||
return
|
||||
}
|
||||
|
||||
const confirmResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody,
|
||||
const confirmBody = { expectedVersion, remark: context.marker }
|
||||
const confirmResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, confirmBody,
|
||||
{ 'Idempotency-Key': confirmKey })
|
||||
const confirmed = context.apiSuccess(MODULE, '确认国内出仓进入等待装船', confirmResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'confirm-dispatch 推进 WAITING_FOR_LOADING 并递增版本', confirmResponse,
|
||||
confirmed?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING'
|
||||
&& confirmed?.aggregate?.inboundStatus === 'LOADED'
|
||||
&& confirmed?.aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED')
|
||||
&& Number(confirmed?.aggregate?.version) === expectedVersion + 1,
|
||||
`dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, inbound=${confirmed?.aggregate?.inboundStatus || 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
`dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, inbounds=${confirmed?.aggregate?.inbounds?.length ?? 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!confirmed?.aggregate) return
|
||||
|
||||
const replayResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody,
|
||||
const replayResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, confirmBody,
|
||||
{ 'Idempotency-Key': confirmKey })
|
||||
const replay = context.apiSuccess(MODULE, 'confirm-dispatch 幂等重放', replayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '确认重放保持同一终态和版本', replayResponse,
|
||||
@@ -117,49 +116,49 @@ export async function run(context) {
|
||||
&& Number(replay?.aggregate?.version) === Number(confirmed?.aggregate?.version),
|
||||
`version=${replay?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const hashConflict = await request(context, 'POST', `${BASE}/confirm-dispatch`, { ...actionBody, remark: 'forged' },
|
||||
const hashConflict = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, { ...confirmBody, remark: 'forged' },
|
||||
{ 'Idempotency-Key': confirmKey })
|
||||
context.addStep(MODULE, '相同 Idempotency-Key 不同请求拒绝', hashConflict,
|
||||
hashConflict.http === 200 && codeOf(hashConflict) === 'A0409',
|
||||
`HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}`, OPTIONS)
|
||||
|
||||
const reverseKey = `${context.marker}-outbound-reverse`.slice(0, 128)
|
||||
const reverseResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, {
|
||||
inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker,
|
||||
const reverseResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/reverse`, {
|
||||
expectedVersion: Number(confirmed.aggregate.version), remark: context.marker,
|
||||
}, { 'Idempotency-Key': reverseKey })
|
||||
context.addStep(MODULE, '实际DEPARTED_WAREHOUSE节点产生后撤销精确返回A0409', reverseResponse,
|
||||
reverseResponse.http === 200 && codeOf(reverseResponse) === 'A0409'
|
||||
&& String(reverseResponse.body?.message || '').includes('DEPARTED_WAREHOUSE')
|
||||
&& String(reverseResponse.body?.message || '').includes('禁止撤销'),
|
||||
`HTTP=${reverseResponse.http}, code=${codeOf(reverseResponse)}, message=${reverseResponse.body?.message || 'missing'}`, OPTIONS)
|
||||
const reverseReplayResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, {
|
||||
inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker,
|
||||
const reverseReplayResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/reverse`, {
|
||||
expectedVersion: Number(confirmed.aggregate.version), remark: context.marker,
|
||||
}, { 'Idempotency-Key': reverseKey })
|
||||
context.addStep(MODULE, '禁止撤销边界重复请求仍精确返回A0409', reverseReplayResponse,
|
||||
reverseReplayResponse.http === 200 && codeOf(reverseReplayResponse) === 'A0409'
|
||||
&& String(reverseReplayResponse.body?.message || '').includes('DEPARTED_WAREHOUSE'),
|
||||
`HTTP=${reverseReplayResponse.http}, code=${codeOf(reverseReplayResponse)}`, OPTIONS)
|
||||
|
||||
const finalDetailResponse = await request(context, 'GET', `${BASE}/detail?${selector}`)
|
||||
const finalDetailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(containerBookingId)}`)
|
||||
const finalDetail = context.apiSuccess(MODULE, '负向与幂等验证后回查出仓聚合', finalDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '出仓聚合保持 WAITING_FOR_LOADING、LOADED 且无成功撤销历史', finalDetailResponse,
|
||||
finalDetail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING'
|
||||
&& finalDetail?.aggregate?.inboundStatus === 'LOADED'
|
||||
&& finalDetail?.aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED')
|
||||
&& Number(finalDetail?.aggregate?.version) === Number(confirmed?.aggregate?.version)
|
||||
&& finalDetail?.timeline?.filter?.((item) => item.module === 'OUTBOUND_DISPATCH'
|
||||
&& item.action === 'CONFIRM_DISPATCH').length === 1
|
||||
&& !finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH'
|
||||
&& item.action === 'REVERSE_DISPATCH'),
|
||||
`dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbound=${finalDetail?.aggregate?.inboundStatus || 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
`dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbounds=${finalDetail?.aggregate?.inbounds?.length ?? 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
if (confirmed?.aggregate) {
|
||||
context.createdResources.phase3OutboundDispatch = {
|
||||
marker: context.marker, inboundId, containerBookingId,
|
||||
orderId: id(confirmed.aggregate.orderId), tenantId: id(confirmed.aggregate.tenantId),
|
||||
orderId: id(inboundBinding?.orderId), tenantId: id(inboundBinding?.tenantId),
|
||||
confirmedStatus: confirmed.aggregate.dispatchStatus,
|
||||
confirmedInboundStatus: confirmed.aggregate.inboundStatus,
|
||||
confirmedInboundStatus: confirmed.aggregate.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED') ? 'LOADED' : null,
|
||||
confirmedVersion: Number(confirmed.aggregate.version),
|
||||
finalStatus: confirmed.aggregate.dispatchStatus,
|
||||
finalInboundStatus: confirmed.aggregate.inboundStatus,
|
||||
finalInboundStatus: confirmed.aggregate.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED') ? 'LOADED' : null,
|
||||
finalVersion: Number(confirmed.aggregate.version),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const read = (name) => readFileSync(resolve(here, name), 'utf8')
|
||||
|
||||
test('current-marker departure batch proves four documents photos customs and container-level confirm', () => {
|
||||
const customs = read('phase3-customs.mjs')
|
||||
const outbound = read('phase3-outbound-dispatch.mjs')
|
||||
for (const type of ['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER']) {
|
||||
assert.match(customs, new RegExp(type))
|
||||
}
|
||||
for (const evidence of ['首次上传', '回显', '下载', '更新', '完成']) assert.match(customs, new RegExp(evidence))
|
||||
assert.match(customs, /photos\.length >= 5/)
|
||||
assert.match(customs, /四类整柜资料必须完成后才允许进入出仓确认/)
|
||||
assert.match(outbound, /CUSTOMS_CLEARANCE/)
|
||||
assert.match(outbound, /拖车与独立船期仅作聚合参考且不参与出仓门禁/)
|
||||
assert.match(outbound, /\$\{BASE\}\/\$\{encodeURIComponent\(containerBookingId\)\}\/confirm/)
|
||||
assert.match(outbound, /confirm-dispatch 幂等重放/)
|
||||
assert.match(outbound, /相同 Idempotency-Key 不同请求拒绝/)
|
||||
assert.match(outbound, /实际DEPARTED_WAREHOUSE节点产生后撤销精确返回A0409/)
|
||||
assert.doesNotMatch(outbound, /\/confirm-dispatch|\/reverse-dispatch|\/check-gate\?/)
|
||||
})
|
||||
|
||||
test('outbound-created query supports automated or manual carrier then reaches PICKUP_READY PDA candidate', () => {
|
||||
const query = read('phase3-carrier-schedule-queries.mjs')
|
||||
const nodes = read('phase4-carrier-nodes.mjs')
|
||||
const pda = read('phase4-overseas-pda.mjs')
|
||||
assert.match(query, /出仓自动建任务兼容自动船司与未知船司人工处理/)
|
||||
assert.match(query, /manualCarrier && task\?\.status === 'MANUAL_REQUIRED'/)
|
||||
assert.match(nodes, /'DISCHARGED_FROM_VESSEL', 'PICKUP_READY'/)
|
||||
assert.match(nodes, /const pickupReadyIndex = STANDARD_NODES\.indexOf\('PICKUP_READY'\)/)
|
||||
assert.match(nodes, /createdResources\.phase4CarrierPickupReady/)
|
||||
assert.match(pda, /targetContainerNo[\s\S]*phase3Loading\?\.containerNo/)
|
||||
assert.match(pda, /id\(carrierQuery\?\.trackingNo\) === targetContainerNo/)
|
||||
assert.match(pda, /seen\.has\(targetContainerNo\)/)
|
||||
assert.match(pda, /targetCorrelated/)
|
||||
assert.match(pda, /deploymentBlocker: true/)
|
||||
assert.doesNotMatch(pda, /addCoverageGap\([^\n]*PICKUP_READY/)
|
||||
})
|
||||
@@ -81,7 +81,7 @@ test('measurement and loading pass destination identity into the LOADED producer
|
||||
assert.match(finalize, /resourceKey: `\$\{context\.marker\}:\$\{fixture\.destinationType \|\| 'UNKNOWN'\}:\$\{fixture\.containerNo \|\| containerBookingId\}`/)
|
||||
assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/)
|
||||
const entry = read('../admin-test-env-acceptance.mjs')
|
||||
assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP'\)/)
|
||||
assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/)
|
||||
assert.match(entry, /orderFulfillmentType/)
|
||||
assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/)
|
||||
assert.match(entry, /skuCodes: Array\.isArray\(fixtureOrder\.skuCodes\) \? fixtureOrder\.skuCodes : \[\]/)
|
||||
@@ -90,3 +90,36 @@ test('measurement and loading pass destination identity into the LOADED producer
|
||||
assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/)
|
||||
assert.match(pda, /skuFixtures\(context, producer\)/)
|
||||
})
|
||||
|
||||
test('only PDA-operated variants traverse real outbound and carrier nodes before overseas receiving', () => {
|
||||
const entry = read('../admin-test-env-acceptance.mjs')
|
||||
assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true\)/)
|
||||
assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/)
|
||||
assert.match(entry, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/)
|
||||
const start = entry.indexOf('if (pickupRequired && producerKey)')
|
||||
const end = entry.indexOf('} finally {', start)
|
||||
const pickupChain = entry.slice(start, end)
|
||||
const ordered = [
|
||||
'phase3-customs.mjs', 'service-center-flow.mjs', 'phase3-port-schedules.mjs',
|
||||
'phase3-outbound-dispatch.mjs', 'phase3-carrier-schedule-queries.mjs',
|
||||
'phase4-carrier-nodes.mjs', 'runUntilPickupReady(this)',
|
||||
]
|
||||
let cursor = -1
|
||||
for (const token of ordered) {
|
||||
const next = pickupChain.indexOf(token)
|
||||
assert.ok(next > cursor, `${token} must follow the real prerequisite chain`)
|
||||
cursor = next
|
||||
}
|
||||
for (const key of ['phase3Customs', 'serviceCenterFlow', 'portSchedule', 'phase3OutboundDispatch',
|
||||
'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady']) assert.match(entry, new RegExp(`'${key}'`))
|
||||
assert.match(entry, /for \(const key of createdKeys\) delete this\.createdResources\[key\]/)
|
||||
assert.match(pickupChain, /shippingReady/)
|
||||
assert.match(entry, /carrierScheduleQueryId/)
|
||||
assert.match(pickupChain, /pickup\?\.marker === variantMarker/)
|
||||
assert.doesNotMatch(pickupChain, /internal\/event-bridge|ll_container_shipping_gate|requestOverseas/)
|
||||
assert.match(entry, /dropshipReady\?\.shippingReady === true/)
|
||||
assert.match(entry, /overseasReady\?\.shippingReady === true/)
|
||||
assert.match(entry, /awaitingVariants\.length >= 1/)
|
||||
assert.doesNotMatch(entry, /awaitingVariants\.length === 2/)
|
||||
assert.match(entry, /awaitingVariants\.every\(\(item\) => item\.shippingReady === false/)
|
||||
})
|
||||
|
||||
@@ -12,9 +12,9 @@ export async function run(context) {
|
||||
'本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture')
|
||||
return
|
||||
}
|
||||
const selector = `inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(fixture.containerBookingId)}`
|
||||
const response = await context.request('GET', `${BASE}/detail?${selector}`)
|
||||
const detail = context.apiSuccess(MODULE, '回查 WAITING_FOR_LOADING 状态历史', response, selector, OPTIONS)
|
||||
const response = await context.request('GET', `${BASE}/${encodeURIComponent(fixture.containerBookingId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '回查柜级 WAITING_FOR_LOADING 状态历史', response,
|
||||
`containerBookingId=${fixture.containerBookingId}`, OPTIONS)
|
||||
const history = Array.isArray(detail?.timeline)
|
||||
? detail.timeline.filter((item) => item.module === 'OUTBOUND_DISPATCH') : []
|
||||
const confirmations = history.filter((item) => item.action === 'CONFIRM_DISPATCH')
|
||||
@@ -27,7 +27,8 @@ export async function run(context) {
|
||||
&& Boolean(confirmed?.operatorId) && Boolean(confirmed?.operatorName) && Boolean(confirmed?.occurredAt),
|
||||
`confirmed=${confirmed?.fromStatus || 'missing'}->${confirmed?.toStatus || 'missing'}, version=${fixture.confirmedVersion ?? 'missing'}`, OPTIONS)
|
||||
context.assert(MODULE, '实际离仓节点冻结后保持一次确认且零次成功撤销', response,
|
||||
detail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' && detail?.aggregate?.inboundStatus === 'LOADED'
|
||||
detail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING'
|
||||
&& detail?.aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED')
|
||||
&& confirmations.length === 1 && reversed.length === 0,
|
||||
`current=${detail?.aggregate?.dispatchStatus || 'missing'}, confirmations=${confirmations.length}, reversals=${reversed.length}`, OPTIONS)
|
||||
}
|
||||
|
||||
@@ -80,7 +80,14 @@ test('cross-region normalization keeps existing producers ahead of every phase-t
|
||||
test('phase-four producer variants restore the primary orchestration fixture after each matrix branch', async () => {
|
||||
const source = await import('node:fs/promises').then((fs) => fs.readFile(
|
||||
new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8'))
|
||||
assert.match(source, /const createdKeys = \['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'\]/)
|
||||
const start = source.indexOf('const createdKeys = [', source.indexOf('async runPhase4ProducerVariant'))
|
||||
const end = source.indexOf(']', start)
|
||||
const createdKeys = source.slice(start, end)
|
||||
for (const key of [
|
||||
'phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking',
|
||||
'phase3CustomsSupplier', 'phase3Customs', 'serviceCenterFlow', 'portSchedule',
|
||||
'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady',
|
||||
]) assert.match(createdKeys, new RegExp(`'${key}'`))
|
||||
})
|
||||
|
||||
test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => {
|
||||
|
||||
@@ -35,26 +35,51 @@ test('PDA module covers the container-number receiving contract and excludes out
|
||||
assert.match(source, /第二仓库操作员抢占同柜返回409/)
|
||||
assert.match(source, /WAREHOUSE_PICKER/)
|
||||
assert.match(source, /lastleg:pda:operate/)
|
||||
assert.match(source, /安全投影已收敛/)
|
||||
assert.match(source, /projectionReady = success\(projectionResponse\) \|\| notFound\(projectionResponse\)/)
|
||||
assert.match(source, /setTimeout\(resolve, 500\)/)
|
||||
assert.match(source, /超收被拒绝/)
|
||||
assert.match(source, /重复箱新键被拒绝/)
|
||||
assert.match(source, /key\(context, `fba-bulk-\$\{index\}`\)/)
|
||||
assert.match(source, /packageCount = Number\(resolved\.data\?\.pendingPackages \|\| 0\)/)
|
||||
assert.match(source, /if \(packageCount <= 0\) continue/)
|
||||
assert.match(source, /key\(context, `overseas-carton-\$\{index\}`\)/)
|
||||
assert.match(source, /模式硬拒绝/)
|
||||
assert.match(source, /current返回404正常空态/)
|
||||
assert.match(source, /安全扫描返回A0400/)
|
||||
assert.match(source, /customerId: 0/)
|
||||
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.match(source, /overseasWarehouseId, password/)
|
||||
assert.match(source, /inbound\?\.overseasWarehouseId/)
|
||||
assert.doesNotMatch(source, /const warehouseId = id\(inbound\?\.destinationWarehouseId\)/)
|
||||
assert.match(source, /目的仓 .* 不得代替物理仓/)
|
||||
assert.match(source, /retained as acceptance evidence/)
|
||||
assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/)
|
||||
assert.match(source, /for \(const \[index, cartonMark\] of cartonMarks\.entries\(\)\)/)
|
||||
assert.match(source, /const cartonCandidates =/)
|
||||
assert.match(source, /candidateExcluded = notFound\(response\)/)
|
||||
assert.match(source, /if \(candidateExcluded\) continue/)
|
||||
assert.match(source, /const fixturesConserved = cartonFixtures\.length === expectedPending/)
|
||||
assert.match(source, /OVERSEAS实装箱唛与待收箱数守恒/)
|
||||
assert.match(source, /for \(const \[index, fixture\] of cartonFixtures\.entries\(\)\)/)
|
||||
assert.doesNotMatch(source, /for \(const \[index, cartonMark\] of cartonMarks\.entries\(\)\)/)
|
||||
assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/)
|
||||
assert.match(source, /phase4OverseasPda\.orderId = id\(order\.id\)/)
|
||||
assert.match(source, /phase4OverseasPda\.customerId = id\(order\.customerId\)/)
|
||||
assert.match(source, /现场库存查询只返回Token绑定仓且包含库存位置/)
|
||||
assert.match(source, /作业历史包含本轮柜号收货动作且分页稳定/)
|
||||
assert.match(source, /awaitProducerConsumption\(context, allProducers\)/)
|
||||
assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/)
|
||||
assert.match(source, /跨区消费生成尾程订单/)
|
||||
assert.match(source, /awaitPickupReadyCandidates\(context, primary\.token, producers, allProducers\)/)
|
||||
assert.match(source, /missingPickupReady=/)
|
||||
assert.match(source, /item\?\.shippingReady === false/)
|
||||
assert.match(source, /!expectedSet\.has\(id\(item\?\.containerNo\)\)/)
|
||||
assert.match(source, /unexpectedAwaiting=/)
|
||||
assert.match(source, /missingPickupReady\.length === 0 && unexpectedAwaiting\.length === 0/)
|
||||
assert.doesNotMatch(source, /shippingReady == null|shippingReady !== true/)
|
||||
assert.match(source, /unexpectedHistorical=/)
|
||||
assert.doesNotMatch(source, /awaitProducerCandidates/)
|
||||
})
|
||||
|
||||
test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => {
|
||||
@@ -97,6 +122,10 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
|
||||
if (method === 'GET' && requestPath.endsWith('/permissions')) return response([
|
||||
{ id: permissionId, permissionCode: 'lastleg:pda:operate' },
|
||||
], requestPath, method)
|
||||
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response({
|
||||
records: [{ id: '193000000000200003', warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }],
|
||||
page: 1, size: 200, total: 1, pages: 1,
|
||||
}, requestPath, method)
|
||||
if (method === 'POST' && requestPath.endsWith('/users')) return response({
|
||||
membershipId: String(9000 + userSequence), username: body.username, tenantId: '0', roleIds: [roleId],
|
||||
overseasWarehouseId: String(body.overseasWarehouseId), status: 'ACTIVE',
|
||||
@@ -107,7 +136,6 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
|
||||
},
|
||||
async requestOverseas(method, requestPath, body, headers, token) {
|
||||
calls.push({ channel: 'overseas', method, requestPath })
|
||||
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('900', requestPath, method)
|
||||
if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200,
|
||||
body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' }
|
||||
return response([], requestPath, method)
|
||||
|
||||
@@ -111,7 +111,21 @@ async function createPicker(context, index, overseasWarehouseId, access) {
|
||||
const token = success(login) ? login.body?.data?.overseasAccessToken : null
|
||||
context.addStep(MODULE, `海外PDA操作员${index}登录取得双区域海外令牌`, login, nonBlank(token),
|
||||
`overseasTokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS)
|
||||
return { membershipId, username, token: nonBlank(token) ? token : null }
|
||||
if (!nonBlank(token)) return { membershipId, username, token: null }
|
||||
|
||||
let projectionResponse = null
|
||||
let projectionReady = false
|
||||
let projectionAttempt = 0
|
||||
for (; projectionAttempt < 30; projectionAttempt += 1) {
|
||||
projectionResponse = await context.requestOverseas('GET',
|
||||
'/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
|
||||
projectionReady = success(projectionResponse) || notFound(projectionResponse)
|
||||
if (projectionReady || !forbidden(projectionResponse)) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
context.addStep(MODULE, `海外PDA操作员${index}安全投影已收敛`, projectionResponse, projectionReady,
|
||||
`attempts=${projectionAttempt + 1}; ${trace(projectionResponse)}`, OPTIONS)
|
||||
return { membershipId, username, token: projectionReady ? token : null }
|
||||
}
|
||||
|
||||
async function disablePicker(context, picker, index) {
|
||||
@@ -205,22 +219,70 @@ async function uploadPhotos(context, token) {
|
||||
return photos
|
||||
}
|
||||
|
||||
async function awaitProducerCandidates(context, token, producers) {
|
||||
async function awaitProducerConsumption(context, producers) {
|
||||
const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))]
|
||||
if (!expected.length) return true
|
||||
const consumed = new Set()
|
||||
const lastResponses = new Map()
|
||||
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
||||
const missing = expected.filter((containerNo) => !consumed.has(containerNo))
|
||||
const results = await Promise.all(missing.map(async (containerNo) => {
|
||||
const response = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
||||
return { containerNo, response }
|
||||
}))
|
||||
for (const { containerNo, response } of results) {
|
||||
lastResponses.set(containerNo, response)
|
||||
const rows = records(response?.body?.data)
|
||||
if (success(response) && rows.some((item) => id(item?.containerNo) === containerNo)) {
|
||||
consumed.add(containerNo)
|
||||
}
|
||||
}
|
||||
if (expected.every((containerNo) => consumed.has(containerNo))) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
for (const containerNo of expected) {
|
||||
const response = lastResponses.get(containerNo) || null
|
||||
context.addStep(MODULE, `跨区消费生成尾程订单 ${containerNo}`, response,
|
||||
consumed.has(containerNo),
|
||||
`containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OPTIONS)
|
||||
}
|
||||
return expected.every((containerNo) => consumed.has(containerNo))
|
||||
}
|
||||
|
||||
async function awaitPickupReadyCandidates(context, token, producers, allProducers) {
|
||||
const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))]
|
||||
const expectedSet = new Set(expected)
|
||||
const awaiting = [...new Set(allProducers
|
||||
.filter((item) => item?.shippingReady === false && !expectedSet.has(id(item?.containerNo)))
|
||||
.map((item) => id(item?.containerNo)).filter(nonBlank))]
|
||||
const known = new Set(allProducers.map((item) => id(item?.containerNo)).filter(nonBlank))
|
||||
if (!expected.length) return false
|
||||
let response = null
|
||||
let seen = new Set()
|
||||
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
||||
response = await context.requestOverseas('GET',
|
||||
'/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token)
|
||||
const records = success(response) && Array.isArray(response.body?.data) ? response.body.data : []
|
||||
seen = new Set(records.map((item) => id(item?.containerNo)).filter(nonBlank))
|
||||
if (expected.every((containerNo) => seen.has(containerNo))) break
|
||||
const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : []
|
||||
seen = new Set(candidateRows.map((item) => id(item?.containerNo)).filter(nonBlank))
|
||||
if (expected.every((containerNo) => seen.has(containerNo))
|
||||
&& awaiting.every((containerNo) => !seen.has(containerNo))) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
const ready = expected.every((containerNo) => seen.has(containerNo))
|
||||
context.addStep(MODULE, '三期生产矩阵全部进入海外待收候选后再开始PDA', response, ready,
|
||||
`expected=${expected.join('|')}, seen=${[...seen].join('|')}; ${trace(response)}`, OPTIONS)
|
||||
const seenExpected = expected.filter((containerNo) => seen.has(containerNo))
|
||||
const missingPickupReady = expected.filter((containerNo) => !seen.has(containerNo))
|
||||
const unexpectedAwaiting = awaiting.filter((containerNo) => seen.has(containerNo))
|
||||
const unexpectedHistorical = [...seen].filter((containerNo) => !known.has(containerNo))
|
||||
const targetContainerNo = id(context.createdResources?.phase3Loading?.containerNo)
|
||||
const carrierQuery = context.createdResources?.phase3CarrierScheduleQuery
|
||||
const pickup = context.createdResources?.phase4CarrierPickupReady
|
||||
const targetCorrelated = nonBlank(targetContainerNo) && expectedSet.has(targetContainerNo)
|
||||
&& seen.has(targetContainerNo) && carrierQuery?.marker === context.marker
|
||||
&& id(carrierQuery?.trackingNo) === targetContainerNo && pickup?.marker === context.marker
|
||||
&& pickup?.ready === true && id(pickup?.queryId) === id(carrierQuery?.queryId)
|
||||
const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 && targetCorrelated
|
||||
context.addStep(MODULE, '实际处理柜全部达到PICKUP_READY并进入PDA候选', response, ready,
|
||||
`targetContainerNo=${targetContainerNo || 'missing'}, targetCorrelated=${targetCorrelated}, expected=${expected.join('|')}, awaiting=${awaiting.join('|') || 'none'}, seenExpected=${seenExpected.join('|') || 'none'}, missingPickupReady=${missingPickupReady.join('|') || 'none'}, unexpectedAwaiting=${unexpectedAwaiting.join('|') || 'none'}, unexpectedHistorical=${unexpectedHistorical.join('|') || 'none'}; ${trace(response)}`, OPTIONS)
|
||||
return ready
|
||||
}
|
||||
|
||||
@@ -253,13 +315,15 @@ async function receiveFba(context, token, inbound, pallet, producer) {
|
||||
}
|
||||
let accepted = 0
|
||||
for (const [index, sku] of skus.entries()) {
|
||||
const { skuCode, packageCount } = sku
|
||||
const { skuCode } = sku
|
||||
const resolvedResponse = await context.requestOverseas('POST',
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token)
|
||||
const resolvable = success(resolvedResponse) || notFound(resolvedResponse)
|
||||
context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS)
|
||||
const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null }
|
||||
if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue
|
||||
const packageCount = Number(resolved.data?.pendingPackages || 0)
|
||||
if (packageCount <= 0) continue
|
||||
const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode,
|
||||
packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) }
|
||||
const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
|
||||
@@ -281,23 +345,52 @@ async function receiveFba(context, token, inbound, pallet, producer) {
|
||||
}
|
||||
|
||||
async function receiveOverseas(context, token, inbounds, pallets, producerCartonMarks = []) {
|
||||
const cartonMarks = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks])
|
||||
const cartonCandidates = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks])
|
||||
.map(id).filter(nonBlank))]
|
||||
if (!cartonMarks.length) {
|
||||
if (!cartonCandidates.length) {
|
||||
blockerGap(context, 'OVERSEAS逐箱链', '缺少本轮PDA任务冻结箱唛,逐箱实收不能使用伪造箱号')
|
||||
return false
|
||||
}
|
||||
for (const [index, cartonMark] of cartonMarks.entries()) {
|
||||
const resolved = await pda(context, token, 'POST', `解析OVERSEAS冻结箱唛${index + 1}`,
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark })
|
||||
const inbound = inbounds.find((item) => item.inboundCode === resolved.data?.inboundCode)
|
||||
const pallet = inbound ? pallets.get(inbound.inboundCode) : null
|
||||
if (!success(resolved.response) || !inbound || !pallet) {
|
||||
blockerGap(context, `OVERSEAS箱唛${cartonMark}归属`,
|
||||
`冻结箱唛未解析到带真实托盘的本柜入仓码;inboundCode=${resolved.data?.inboundCode || 'missing'}`,
|
||||
'fixture-mismatch', resolved.response)
|
||||
const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0)
|
||||
const cartonFixtures = []
|
||||
let lastResponse = null
|
||||
for (const [index, cartonMark] of cartonCandidates.entries()) {
|
||||
const response = await context.requestOverseas('POST',
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, {}, token)
|
||||
lastResponse = response
|
||||
const candidateExcluded = notFound(response)
|
||||
context.addStep(MODULE, `筛选OVERSEAS实装箱唛候选${index + 1}`, response,
|
||||
success(response) || candidateExcluded,
|
||||
`${candidateExcluded ? 'candidateSelected=false' : 'candidateSelected=true'}; ${trace(response)}`, OPTIONS)
|
||||
if (candidateExcluded) continue
|
||||
if (!success(response)) {
|
||||
blockerGap(context, `OVERSEAS箱唛候选${cartonMark}预检`,
|
||||
'候选箱唛既非本柜冻结事实,也不是未被最终实装选中的A0404;禁止继续收货',
|
||||
'fixture-mismatch', response)
|
||||
return false
|
||||
}
|
||||
const resolved = response.body?.data
|
||||
const inbound = inbounds.find((item) => item.inboundCode === resolved?.inboundCode)
|
||||
const pallet = inbound ? pallets.get(inbound.inboundCode) : null
|
||||
if (!inbound || !pallet) {
|
||||
blockerGap(context, `OVERSEAS箱唛${cartonMark}归属`,
|
||||
`冻结箱唛未解析到带真实托盘的本柜入仓码;inboundCode=${resolved?.inboundCode || 'missing'}`,
|
||||
'fixture-mismatch', response)
|
||||
return false
|
||||
}
|
||||
cartonFixtures.push({ cartonMark, pallet })
|
||||
}
|
||||
const fixturesConserved = cartonFixtures.length === expectedPending
|
||||
context.assert(MODULE, 'OVERSEAS实装箱唛与待收箱数守恒', lastResponse, fixturesConserved,
|
||||
`resolvedFixtures=${cartonFixtures.length}, pendingPackages=${expectedPending}; ${trace(lastResponse)}`, OPTIONS)
|
||||
if (!fixturesConserved) {
|
||||
blockerGap(context, 'OVERSEAS逐箱冻结事实',
|
||||
`候选池未完整覆盖本柜真实冻结箱唛;resolvedFixtures=${cartonFixtures.length}, pendingPackages=${expectedPending}`,
|
||||
'fixture-mismatch', lastResponse)
|
||||
return false
|
||||
}
|
||||
for (const [index, fixture] of cartonFixtures.entries()) {
|
||||
const { cartonMark, pallet } = fixture
|
||||
const body = { cartonMark, palletCode: pallet.palletCode,
|
||||
idempotencyKey: key(context, `overseas-carton-${index}`) }
|
||||
const first = await pda(context, token, 'POST', `OVERSEAS逐箱实收${index + 1}`,
|
||||
@@ -466,9 +559,9 @@ export async function run(context) {
|
||||
const merchantToken = await loginMerchantForNegativeProbe(context)
|
||||
const pickerAccess = await resolvePickerAccess(context)
|
||||
if (!pickerAccess) return
|
||||
const sharedWarehouse = await createSharedWarehouse(context)
|
||||
const sharedWarehouse = await resolveSharedWarehouse(context)
|
||||
if (!sharedWarehouse) {
|
||||
blockerGap(context, '海外PDA共享实际收货仓', '无法通过平台正式API创建customerId=0共享物理仓')
|
||||
blockerGap(context, '海外PDA共享实际收货仓', '国内Warehouse主数据没有ACTIVE且启用RECEIVING的OVERSEAS固定作业仓')
|
||||
return
|
||||
}
|
||||
context.createdResources.phase4OverseasPda.overseasWarehouseId = sharedWarehouse.id
|
||||
@@ -501,9 +594,14 @@ export async function run(context) {
|
||||
const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]]
|
||||
.filter((item) => nonBlank(item?.containerNo))
|
||||
const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers
|
||||
if (allProducers.length && !(await awaitProducerCandidates(context, primary.token, allProducers))) {
|
||||
blockerGap(context, '海外待收柜生产矩阵',
|
||||
'三期已发布的FBA、OVERSEAS和第二柜尚未全部被海外事件消费者提交,禁止与消费事务并发入库')
|
||||
if (allProducers.length && !(await awaitProducerConsumption(context, allProducers))) {
|
||||
blockerGap(context, '跨区消费生产矩阵',
|
||||
'三期已发布的四柜尚未全部由海外消费者生成尾程订单,禁止把PICKUP_READY缺口与事件消费混为一谈')
|
||||
return
|
||||
}
|
||||
if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) {
|
||||
blockerGap(context, '实际处理柜PICKUP_READY门禁',
|
||||
'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜')
|
||||
return
|
||||
}
|
||||
for (const [index, producer] of (producers.length ? producers : [null]).entries()) {
|
||||
@@ -525,21 +623,14 @@ export async function run(context) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createSharedWarehouse(context) {
|
||||
const suffix = markerSuffix(context)
|
||||
const payload = {
|
||||
customerId: 0, warehouseCode: `PDA-${suffix}`.slice(0, 50),
|
||||
warehouseName: `PDA共享收货仓-${suffix}`.slice(0, 120), warehouseType: 'OVERSEAS',
|
||||
countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'PDA Acceptance',
|
||||
contactPhone: '+1-213-555-0198', contactEmail: `pda-${suffix.toLowerCase()}@example.com`,
|
||||
status: 'ACTIVE', remark: `${context.marker}-shared-receiving`,
|
||||
address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'CA', city: 'Los Angeles',
|
||||
district: 'Commerce', postalCode: '90040', addressLine1: '1 Acceptance Logistics Ave' },
|
||||
capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: '收货', enabled: true }],
|
||||
}
|
||||
const created = await context.requestOverseas('POST', '/api/platform/v1/warehouse-admin', payload)
|
||||
const warehouseId = id(created?.body?.data)
|
||||
context.addStep(MODULE, '创建本轮PDA共享实际收货仓', created,
|
||||
success(created) && longId(warehouseId), `warehouseId=${warehouseId || 'missing'}; ${trace(created)}`, OPTIONS)
|
||||
return longId(warehouseId) ? { id: warehouseId, name: payload.warehouseName } : null
|
||||
async function resolveSharedWarehouse(context) {
|
||||
const path = '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE'
|
||||
const response = await context.request('GET', path)
|
||||
const rows = records(response?.body?.data)
|
||||
const fixedIds = new Set(['193000000000200003', '193000000000200004'])
|
||||
const warehouse = success(response) ? rows.find((item) => fixedIds.has(id(item?.id))) : null
|
||||
context.addStep(MODULE, '读取国内Warehouse权威海外PDA实际收货仓', response,
|
||||
Boolean(warehouse) && warehouse?.warehouseType === 'OVERSEAS' && warehouse?.status === 'ACTIVE',
|
||||
`warehouseId=${id(warehouse?.id) || 'missing'}, warehouseName=${warehouse?.warehouseName || warehouse?.name || 'missing'}; ${trace(response)}`, OPTIONS)
|
||||
return warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ export async function run(context) {
|
||||
const module = 'supplemental'
|
||||
const scenario = 'service-health-and-auth-boundary'
|
||||
const blocker = { scenario, deploymentBlocker: true }
|
||||
const customerId = String(context.context.merchantQualificationFixture?.tenantId || '')
|
||||
context.assert(module, '客户作用域负向检查使用本轮正式客户', null, Boolean(customerId),
|
||||
`customerId=${customerId || 'missing'}`, blocker)
|
||||
if (!customerId) return
|
||||
|
||||
// Health endpoints are part of the deployed service contract. Warehouse is
|
||||
// special: a schema/readiness failure is intentionally HTTP 503, not a fake
|
||||
@@ -102,7 +106,7 @@ export async function run(context) {
|
||||
}
|
||||
for (const [name, path] of [
|
||||
['仓库非法状态', '/api/platform/v1/warehouse-admin?page=1&size=5&status=NOT_A_STATUS'],
|
||||
['仓库客户作用域', '/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=10001'],
|
||||
['仓库客户作用域', `/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`],
|
||||
]) {
|
||||
const response = await context.request('GET', path)
|
||||
context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response),
|
||||
@@ -118,8 +122,8 @@ export async function run(context) {
|
||||
await checkPage(context, module, '托盘仓库筛选', `${palletBase}&warehouseId=${warehouseId}`, { scenario: 'warehouse-resources' })
|
||||
}
|
||||
const resourceScopeChecks = [
|
||||
['库位客户作用域', '/api/platform/v1/locations?page=1&size=5&tenantId=10001'],
|
||||
['托盘客户作用域', '/api/platform/v1/pallets?page=1&size=5&tenantId=10001'],
|
||||
['库位客户作用域', `/api/platform/v1/locations?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`],
|
||||
['托盘客户作用域', `/api/platform/v1/pallets?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`],
|
||||
['库位非法状态', `${locationBase}&status=ALLOCATED`],
|
||||
]
|
||||
for (const [name, path] of resourceScopeChecks) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const MODULE = 'suppliers'
|
||||
const OPTIONS = { scenario: 'platform-public-suppliers', deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/suppliers'
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
|
||||
function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
||||
function code(response, expected) { return response?.http === 200 && String(response?.body?.code) === expected }
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# 批量待分柜数据生成指南
|
||||
|
||||
本文说明如何复用 `scripts/admin-test-env-acceptance.mjs`,通过正式 HTTP 业务接口批量生成停在“测量完成待分柜”的头程订单。生成链路包含客户登录、下单、平台审核、入库、称重、测量、测量审核,不直接写业务数据库,也不模拟成功。
|
||||
|
||||
## 适用范围
|
||||
|
||||
- 用于开发、联调或经明确授权的验收环境准备人工操作数据。
|
||||
- 生成结果固定为入库单 `processStatus=WAIT_SPLIT`、测量审核 `APPROVED`。
|
||||
- 单次支持 `1-100` 条;更大批量分多次执行并分别保存报告。
|
||||
- 不负责后续分柜、装柜、订舱、国内出仓、海外入仓或 PDA 收货。
|
||||
- 禁止用于生产环境。对任何共享环境执行前,必须在当前对话明确获得该环境的写数据授权。
|
||||
|
||||
## 固定项与随机项
|
||||
|
||||
批量模式可固定以下三个线路字段:
|
||||
|
||||
1. 国内起运仓名称:`--origin-warehouse-name`
|
||||
2. 渠道名称:`--channel-name`
|
||||
3. 渠道编码:`--channel-code`
|
||||
|
||||
客户通过复用已审核商户账号固定;服务端从登录身份解析客户,脚本不会手填客户 ID。
|
||||
|
||||
使用 `--order-profile-mode randomized` 后,脚本在服务端允许的真实组合中轮换:
|
||||
|
||||
- 目的类型:`FBA`、`OVERSEAS`、`PRIVATE`
|
||||
- 履约类型:`STANDARD`、`ONE_CLICK_DROPSHIP`
|
||||
- 提货方式:`PICKUP`、`SELF_EXPRESS`、`SELF_TRUCK`
|
||||
- 买单/退税:`BUY_DECLARATION`、`TAX_REFUND`
|
||||
- 报价税制:`TAX_INCLUDED`、`TAX_EXCLUDED`,以固定渠道实际支持项为准
|
||||
- 装载方式:`LCL`、`FULL_CONTAINER`
|
||||
- 是否查验
|
||||
- SKU 数、每 SKU 箱数、托数、重量和测量箱型
|
||||
- FBA/海外仓目的仓:只从固定渠道当前价格簿真实可计价仓库中选择
|
||||
|
||||
随机档案是可重复的稳定轮换,不是不可复现的纯随机数。每六条覆盖一次“3 种目的类型 × 2 种履约类型”的核心组合,便于复核覆盖率。
|
||||
|
||||
特殊规则:
|
||||
|
||||
- `PRIVATE` 不引用公共目的仓 ID,必须携带收件人地址;当前契约不对私人地址做自动报价。
|
||||
- `FBA` 必须使用真实可计价 FBA 目的仓,并冻结 Amazon 仓码、Shipment ID 和 Reference ID。
|
||||
- `OVERSEAS` 必须使用真实可计价海外仓;固定渠道只有一个可用海外仓时允许重复使用,不伪造第二个仓。
|
||||
- 默认 `--order-profile-mode fixed` 仍生成 FBA + STANDARD,不启用上述随机组合。
|
||||
|
||||
## 执行前置
|
||||
|
||||
执行前确认:
|
||||
|
||||
- 目标 Gateway 和服务已部署当前接口。
|
||||
- 平台管理员账号可用。
|
||||
- 复用客户已审核通过,商户账号可登录。
|
||||
- 客户固定箱型初始化完成。
|
||||
- 固定国内仓为 `ACTIVE` 且具备收货能力。
|
||||
- 固定渠道存在当前 `ACTIVE` 价格簿和进仓码前缀。
|
||||
- FBA/OVERSEAS 至少存在一个与固定渠道价格明细匹配的目的仓。
|
||||
- File、Order、Firstleg、Warehouse、IAM、Auth 等链路可用。
|
||||
|
||||
账号、密码、环境域名和 Token 不得写入本文、源码、提交记录或报告。建议由本地未提交环境文件或当前终端环境变量提供。
|
||||
|
||||
## PowerShell 命令模板
|
||||
|
||||
先在当前终端提供敏感执行参数:
|
||||
|
||||
```powershell
|
||||
$env:ADMIN_TEST_GATEWAY = '<目标环境国内 Gateway>'
|
||||
$env:ADMIN_TEST_USERNAME = '<平台管理员账号>'
|
||||
$env:ADMIN_TEST_PASSWORD = '<平台管理员密码>'
|
||||
$env:MERCHANT_TEST_USERNAME = '<已审核商户账号>'
|
||||
$env:MERCHANT_TEST_PASSWORD = '<已审核商户密码>'
|
||||
```
|
||||
|
||||
从 `baiyue-firstleg/` 执行:
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules inbound-measurement `
|
||||
--merchant-username $env:MERCHANT_TEST_USERNAME `
|
||||
--merchant-password $env:MERCHANT_TEST_PASSWORD `
|
||||
--origin-warehouse-name '<固定国内仓名称>' `
|
||||
--channel-name '<固定渠道名称>' `
|
||||
--channel-code '<固定渠道编码>' `
|
||||
--order-profile-mode randomized `
|
||||
--wait-split-count 100 `
|
||||
--output '<本地报告绝对路径>.json'
|
||||
```
|
||||
|
||||
`inbound-measurement` 会自动展开 `orders` 前置。提供完整商户账号和密码后,总入口会复用该客户并移除自动创建客户的 `merchant-qualification-fixture`;两个商户参数必须同时提供。
|
||||
|
||||
如果需要自动新建并审核一个隔离客户,不传复用商户账号,改用:
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules merchant-qualification-fixture,orders,inbound-measurement `
|
||||
--customer-name '<本轮客户名称>' `
|
||||
--origin-warehouse-name '<固定国内仓名称>' `
|
||||
--channel-name '<固定渠道名称>' `
|
||||
--channel-code '<固定渠道编码>' `
|
||||
--order-profile-mode randomized `
|
||||
--wait-split-count 100 `
|
||||
--output '<本地报告绝对路径>.json'
|
||||
```
|
||||
|
||||
## 报告核验
|
||||
|
||||
成功报告中的 `createdResources.waitSplitFixtures` 每条至少应核对:
|
||||
|
||||
- `tenantId`、`orderId`、`inboundId`、`inboundCode`
|
||||
- `processStatus=WAIT_SPLIT`
|
||||
- `reviewStatus=APPROVED`
|
||||
- `receivingWarehouseName`、`channelCode`、`channelName`
|
||||
- `destinationType`、目的仓字段、`fulfillmentType`
|
||||
- `pickupMethod`、`taxMode`、`pricingTaxMode`、`containerMode`、`customsInspection`
|
||||
- SKU 数、总箱数、托数、毛重、净重和测量档案
|
||||
|
||||
建议同时检查:
|
||||
|
||||
- fixture 数量等于本轮 `--wait-split-count`
|
||||
- `inboundCode` 去重数等于 fixture 数量
|
||||
- 固定客户、国内仓和渠道只有一个唯一值
|
||||
- `failed=0` 且报告顶层 `success=true`
|
||||
- 随机批次包含预期目的类型和履约类型
|
||||
|
||||
报告生成前会扫描密码、Token、文件二进制、base64 和 OCR 原文等敏感内容。报告仍属于本地验收证据,不应提交到源码仓库。
|
||||
|
||||
## 失败、重跑与批量拆分
|
||||
|
||||
- 批次遇到第一条失败即停止,已经成功创建的订单不会自动删除或回滚。
|
||||
- 先查看日志和 JSON 报告中最后一个成功 fixture、失败接口的 `code/requestId/traceId`,修复前置后再运行剩余数量。
|
||||
- 重跑会生成新业务单号和入仓码,不覆盖旧订单;不要假设跨多次运行的入仓码一定连续。
|
||||
- 每次运行的随机序列从 1 重新开始;分批执行时组合可能重复,但仍保持固定三项和真实契约。
|
||||
- 大于 100 条时建议按 50-100 条拆批,并为每批使用独立报告文件名。
|
||||
- 若固定渠道只有少量目的仓,目的仓重复是合法结果;脚本只保证从真实可计价集合选择,不保证每批目的仓均匀分布。
|
||||
|
||||
## 常见阻断
|
||||
|
||||
| 现象 | 核对项 |
|
||||
|---|---|
|
||||
| 未找到固定起运仓 | 仓库名称是否精确一致、状态是否 ACTIVE、类型是否 DOMESTIC |
|
||||
| 未找到固定物流渠道 | 渠道编码/名称是否精确一致、价格簿是否 ACTIVE、起运仓是否匹配 |
|
||||
| 没有可计价目的仓 | 价格簿目的类型、目的仓映射、报价税制和仓库状态 |
|
||||
| 私人地址创建失败 | `destinationWarehouseId` 必须为空,收件人字段必须完整 |
|
||||
| 测量审核未到 WAIT_SPLIT | 称重图片、托数、净重、总箱数和测量箱数是否精确核平 |
|
||||
| 报告数量不足 | 查看首个失败模块;已成功数据不会因后续失败回滚 |
|
||||
|
||||
## 离线验证
|
||||
|
||||
修改生成器或本文关联参数后,至少运行:
|
||||
|
||||
```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
|
||||
git diff --check
|
||||
```
|
||||
|
||||
离线检查不访问环境,也不代表 HTTP 批量生成已经通过。真实生成必须另行获得目标环境写数据授权。
|
||||
@@ -28,6 +28,13 @@ function Assert-Contains([string]$text, [string]$needle, [string]$scope) {
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-NotContains([string]$text, [string]$needle, [string]$scope) {
|
||||
$script:checked++
|
||||
if ($text.Contains($needle)) {
|
||||
Add-Failure "$scope 仍残留: $needle"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Matches([string]$text, [string]$pattern, [string]$scope) {
|
||||
$script:checked++
|
||||
if ($text -notmatch $pattern) {
|
||||
@@ -77,6 +84,13 @@ $clientAgents = Read-RequiredText $clientAgentsPath
|
||||
$pdaAgents = Read-RequiredText $pdaAgentsPath
|
||||
$overseasPdaAgents = Read-RequiredText $overseasPdaAgentsPath
|
||||
$backendAgents = Read-RequiredText $backendAgentsPath
|
||||
$engineeringConstraints = Read-RequiredText (Join-Path $workspaceRoot 'docs/AI工程执行与协作约束.md')
|
||||
|
||||
# 当前未上线阶段的无兼容原则必须覆盖全部实现与运行状态,不能再次缩窄为文件或数据库专项规则。
|
||||
Assert-Contains $rootAgents '源码结构、接口与 DTO、字段、状态机、权限、配置键、数据库结构与初始化 SQL、事件、路由、页面、Mock/Fixture、测试和脚本' '根无兼容收敛范围'
|
||||
Assert-Contains $rootAgents '数据库记录、上传文件、缓存、会话、消息、搜索索引和其他运行状态均视为可丢弃' '根开发运行状态范围'
|
||||
Assert-Contains $engineeringConstraints '禁止为旧代码、旧调用或开发环境存量保留旧接口/旧字段别名' '工程无兼容执行规则'
|
||||
Assert-Contains $engineeringConstraints '不授权自动重置数据库、删除数据/文件、执行 SQL、部署或修改环境' '无兼容安全边界'
|
||||
|
||||
# 部署单元必须能从根索引进入。
|
||||
Get-ChildItem -LiteralPath (Join-Path $workspaceRoot 'apps') -Directory |
|
||||
@@ -156,6 +170,57 @@ foreach ($gatewayPath in @(
|
||||
Assert-Contains $overseasGatewayConfig 'uri: lb://lastleg-service' 'Overseas Gateway Lastleg 路由'
|
||||
Assert-Contains $overseasGatewayConfig 'uri: lb://warehouse-overseas-service' 'Overseas Gateway Warehouse 路由'
|
||||
|
||||
# 海外安全投影已并入 Lastleg,Gateway 校验和 Event Bridge 消费均指向同一海外服务。
|
||||
$serviceMap = Read-RequiredText (Join-Path $backendRoot 'deploy/service-map.yml')
|
||||
$overseasCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.overseas.yml')
|
||||
$serviceJenkinsfile = Read-RequiredText (Join-Path $backendRoot 'Jenkinsfile.service')
|
||||
Assert-Contains $serviceMap 'lastleg-service:' 'Lastleg 服务部署映射'
|
||||
Assert-Contains $overseasCompose '/internal/lastleg/security/v1/events' 'Lastleg 安全事件消费地址'
|
||||
Assert-Contains $overseasCompose 'LASTLEG_SECURITY_SERVICE_URL' 'Lastleg 安全校验地址'
|
||||
Assert-NotContains $serviceJenkinsfile "'overseas-security-service'" '独立海外安全服务构建入口'
|
||||
|
||||
# 高变化语义必须在最近指引与服务契约中保持一致。
|
||||
Assert-Contains $adminAgents '海外出库(范围外现存代码)' 'Admin 四期范围边界'
|
||||
Assert-Contains $pdaAgents 'VITE_H5_API_BASE_URL' '国内 PDA H5 运行配置'
|
||||
Assert-Contains $overseasPdaAgents 'tests/app-icons.test.mjs' '海外 PDA 图标合同'
|
||||
Assert-Contains $backendAgents 'RestClientTraceInterceptor.java' '后端同步 Trace 入口'
|
||||
$oceanBookingOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
|
||||
Assert-Contains $oceanBookingOpenApi 'never represents linked or ACTIVE containers' '订舱柜量语义'
|
||||
Assert-Contains $oceanBookingOpenApi '/api/platform/v1/ocean-bookings/link-candidates:' '订舱关联候选契约'
|
||||
Assert-Contains $oceanBookingOpenApi 'exact warehouseId, channelCode and containerType' '订舱关联候选筛选语义'
|
||||
|
||||
$adminOceanBookingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts')
|
||||
$adminOceanBookingView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue')
|
||||
$adminOceanBookingViewTest = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts')
|
||||
Assert-Contains $adminOceanBookingApi '/link-candidates' 'Admin 订舱关联候选调用'
|
||||
Assert-Contains $adminOceanBookingView "label: '订舱柜量'" 'Admin 订舱柜量文案'
|
||||
Assert-Contains $adminOceanBookingViewTest 'without detail N+1' 'Admin 订舱列表无详情扇出合同'
|
||||
|
||||
# 已注册但超出当前四期边界的海外出库只能作为范围漂移事实存在,不得进入根正式业务路由。
|
||||
$adminOverseasRoutes = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/routes.ts')
|
||||
Assert-Contains $adminOverseasRoutes "path: 'overseas-warehouse/outbounds'" 'Admin 海外出库现存路由'
|
||||
Assert-Contains $adminAgents '海外出库(范围外现存代码)' 'Admin 海外出库范围警示'
|
||||
Assert-NotContains $rootAgents 'OutboundsView.vue' '根正式四期业务路由'
|
||||
|
||||
# Admin 仅对静态资源过期做一次受控恢复,不能吞掉普通路由错误或覆盖未保存编辑。
|
||||
$navigationRecovery = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/core/routing/navigationRecovery.ts')
|
||||
Assert-Contains $navigationRecovery 'const RECOVERY_WINDOW_MS = 30_000' 'Admin 路由恢复窗口'
|
||||
Assert-Contains $navigationRecovery 'if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true' 'Admin 未保存编辑保护'
|
||||
Assert-Contains $navigationRecovery 'if (!isStaleRouteAssetError(error))' 'Admin 普通路由错误边界'
|
||||
|
||||
# 国内 PDA 的 H5 地址必须来自当前构建和同源代理,不能被历史登录缓存覆盖。
|
||||
$domesticPdaRuntime = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/config/runtime.js')
|
||||
$domesticPdaVite = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/vite.config.js')
|
||||
$domesticPdaSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/stores/session.js')
|
||||
Assert-Contains $domesticPdaRuntime "VITE_H5_API_BASE_URL || '/api'" '国内 PDA H5 同源 API'
|
||||
Assert-Contains $domesticPdaVite 'API_PROXY_TARGET' '国内 PDA H5 代理上游'
|
||||
Assert-Contains $domesticPdaSession 'session.apiBase = PLATFORM_API_ROOT' '国内 PDA 当前构建 API'
|
||||
Assert-NotContains $domesticPdaSession 'apiBase: session.apiBase' '国内 PDA 会话持久化'
|
||||
|
||||
# 同步 RestClient Trace 必须注册为公共自动配置,不能只有孤立拦截器源码。
|
||||
$commonWebAutoConfigurations = Read-RequiredText (Join-Path $backendRoot 'common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports')
|
||||
Assert-Contains $commonWebAutoConfigurations 'com.baiyue.common.web.RestClientTracePropagationAutoConfiguration' 'RestClient Trace 自动配置注册'
|
||||
|
||||
# Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。
|
||||
[xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw
|
||||
foreach ($module in $pom.project.modules.module) {
|
||||
@@ -173,7 +238,17 @@ $criticalPaths = @(
|
||||
'apps/admin-web/src/modules/overseas-warehouse/pages/DispatchDetailView.vue',
|
||||
'apps/admin-web/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue',
|
||||
'apps/admin-web/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue',
|
||||
'apps/admin-web/src/modules/container-loading/pages/ContainerDetailView.vue',
|
||||
'apps/admin-web/src/modules/container-loading/components/ContainerOverviewPanel.vue',
|
||||
'apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts',
|
||||
'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts',
|
||||
'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue',
|
||||
'apps/admin-web/src/core/routing/navigationRecovery.ts',
|
||||
'apps/admin-web/src/core/routing/navigationRecovery.test.ts',
|
||||
'apps/admin-web/src/modules/overseas-warehouse/api/outbounds.ts',
|
||||
'apps/admin-web/src/modules/overseas-warehouse/pages/OutboundsView.vue',
|
||||
'apps/admin-web/src/modules/iam/pages/InternalUsersView.vue',
|
||||
'apps/admin-web/src/modules/iam/api/platformIam.ts',
|
||||
'apps/client-web/src/api/overseasHttp.ts',
|
||||
'apps/client-web/src/modules/overseas-warehouse/routes.ts',
|
||||
'apps/client-web/src/modules/overseas-warehouse/api/warehouseServices.ts',
|
||||
@@ -181,6 +256,9 @@ $criticalPaths = @(
|
||||
'apps/admin-web/src/modules/overseas-business/api/platformOverseasBusiness.ts',
|
||||
'apps/admin-web/src/modules/overseas-master-data/api/overseasMasterData.ts',
|
||||
'apps/pda-uniapp/pages/tasks/tasks.vue',
|
||||
'apps/pda-uniapp/config/runtime.js',
|
||||
'apps/pda-uniapp/.env.example',
|
||||
'apps/pda-uniapp/vite.config.js',
|
||||
'apps/pda-uniapp/utils/evidence-upload-queue.js',
|
||||
'apps/pda-overseas-uniapp/package.json',
|
||||
'apps/pda-overseas-uniapp/src/pages.json',
|
||||
@@ -192,6 +270,8 @@ $criticalPaths = @(
|
||||
'apps/pda-overseas-uniapp/src/api/receiving-adapter.js',
|
||||
'apps/pda-overseas-uniapp/src/api/overseas.js',
|
||||
'apps/pda-overseas-uniapp/src/utils/scan-broadcast.js',
|
||||
'apps/pda-overseas-uniapp/src/manifest.json',
|
||||
'apps/pda-overseas-uniapp/tests/app-icons.test.mjs',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java',
|
||||
@@ -203,10 +283,39 @@ $criticalPaths = @(
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java',
|
||||
'baiyue-firstleg/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java',
|
||||
'baiyue-firstleg/firstleg/docs/firstleg-internal-modularization-baseline.md',
|
||||
'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.mjs',
|
||||
'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.test.mjs',
|
||||
'baiyue-firstleg/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json',
|
||||
'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java',
|
||||
'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java',
|
||||
'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java',
|
||||
'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java',
|
||||
'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java',
|
||||
'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java',
|
||||
'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java',
|
||||
'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java',
|
||||
'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java',
|
||||
'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java',
|
||||
'baiyue-firstleg/iam/src/main/resources/db/customer-dimension-initialization-task.sql',
|
||||
'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java',
|
||||
'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java',
|
||||
'baiyue-firstleg/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java',
|
||||
'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java',
|
||||
'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java',
|
||||
'baiyue-firstleg/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java',
|
||||
'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java',
|
||||
'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java',
|
||||
@@ -220,6 +329,18 @@ $criticalPaths = @(
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml',
|
||||
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java',
|
||||
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java',
|
||||
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java',
|
||||
'baiyue-firstleg/lastleg-service/docs/lastleg-internal-modularization-baseline.md',
|
||||
'baiyue-firstleg/lastleg-service/scripts/analyze-lastleg-dependencies.mjs',
|
||||
'baiyue-firstleg/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs',
|
||||
'baiyue-firstleg/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java',
|
||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java',
|
||||
@@ -240,6 +361,8 @@ $criticalPaths = @(
|
||||
'baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java',
|
||||
'baiyue-firstleg/deploy/scripts/deploy-built-service.sh',
|
||||
'baiyue-firstleg/scripts/deploy-built-service-rollback.test.mjs',
|
||||
'baiyue-firstleg/scripts/java-quality-summary.mjs',
|
||||
'baiyue-firstleg/scripts/java-quality-summary.test.mjs',
|
||||
'docs/四期/船节点标准化契约.md'
|
||||
)
|
||||
$criticalPaths | ForEach-Object { Assert-Path $_ }
|
||||
@@ -255,13 +378,19 @@ $indexedAgentFiles = @(
|
||||
(Join-Path $backendRoot 'firstleg/AGENTS.md'),
|
||||
(Join-Path $backendRoot 'finance/AGENTS.md'),
|
||||
(Join-Path $backendRoot 'lastleg-service/AGENTS.md'),
|
||||
(Join-Path $backendRoot 'iam/AGENTS.md'),
|
||||
(Join-Path $backendRoot 'common-security/AGENTS.md'),
|
||||
(Join-Path $backendRoot 'common-web/AGENTS.md'),
|
||||
(Join-Path $backendRoot 'common-openapi/AGENTS.md'),
|
||||
(Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'),
|
||||
(Join-Path $backendRoot 'event-bridge-service/AGENTS.md'),
|
||||
(Join-Path $backendRoot 'file/AGENTS.md'),
|
||||
(Join-Path $backendRoot 'common-event-contract/AGENTS.md')
|
||||
)
|
||||
$excludedTargetPattern = '(?i)`(?:[^`]*/)?(node_modules|node_modules\.broken-by-path|dist|target|reports|output|outputs|uni_modules|\.git|\.codex[^/`]*|\.runtime[^/`]*)(?:/|`)'
|
||||
$excludedTargetPattern = '(?i)`(?:[^`]*/)?(node_modules(?:\.[^/`]*)?|dist|target|reports|output|outputs|uni_modules|\.git|\.codex[^/`]*|\.runtime[^/`]*)(?:/|`)'
|
||||
$excludedPatternSamples = @(
|
||||
'| 错误入口 | `node_modules/pkg/` |',
|
||||
'| 错误入口 | `node_modules.corrupt/.bin/vite` |',
|
||||
'| 错误入口 | `src/target/classes/` |',
|
||||
'| 错误入口 | `.codex-temp/task/` |',
|
||||
'| 错误入口 | `.runtime-tailleg/DbQuery.java` |'
|
||||
@@ -293,5 +422,5 @@ if ($failures.Count -gt 0) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、关键入口和排除目录。" -ForegroundColor Green
|
||||
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、四期范围漂移、订舱候选与柜量语义、Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
|
||||
exit 0
|
||||
|
||||
@@ -19,7 +19,7 @@ const args = parseArgs(process.argv.slice(2))
|
||||
const gateway = trimSlash(args.gateway ?? process.env.CLIENT_PHASE2_TEST_GATEWAY
|
||||
?? 'http://8.163.30.128:9000')
|
||||
const clientUsername = args['client-username'] ?? process.env.CLIENT_PHASE2_USERNAME
|
||||
?? await promptText('二期客户端账号', 'merchant_admin')
|
||||
?? await promptText('二期客户端账号(必须由正式注册/建档产生)', '')
|
||||
const clientPassword = args['client-password'] ?? process.env.CLIENT_PHASE2_PASSWORD
|
||||
?? await promptSecret('二期客户端密码')
|
||||
const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERNAME
|
||||
@@ -30,8 +30,8 @@ const forcedOrderId = args['order-id'] ?? process.env.CLIENT_PHASE2_ORDER_ID ??
|
||||
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_PHASE2_REPORT_DIR ?? 'reports')
|
||||
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_PHASE2_TIMEOUT_MS ?? 30_000)
|
||||
|
||||
if (!clientPassword || !platformPassword) {
|
||||
console.error('Client and platform passwords are required')
|
||||
if (!clientUsername || !clientPassword || !platformPassword) {
|
||||
console.error('An onboarded Client account and platform credentials are required')
|
||||
process.exit(2)
|
||||
}
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
|
||||
|
||||
@@ -24,14 +24,14 @@ const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERN
|
||||
const platformPassword = args['platform-password'] ?? process.env.PLATFORM_PASSWORD
|
||||
?? await promptSecret('平台审核密码')
|
||||
const existingClientUsername = args['existing-client-username'] ?? process.env.EXISTING_CLIENT_USERNAME
|
||||
?? await promptText('跨客户隔离测试账号', 'merchant_admin')
|
||||
?? await promptText('跨客户隔离测试账号(必须由正式注册/建档产生)', '')
|
||||
const existingClientPassword = args['existing-client-password'] ?? process.env.EXISTING_CLIENT_PASSWORD
|
||||
?? await promptSecret('跨客户隔离测试密码')
|
||||
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports')
|
||||
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000)
|
||||
|
||||
if (!platformPassword) {
|
||||
console.error('Platform password is required')
|
||||
if (!platformPassword || !existingClientUsername || !existingClientPassword) {
|
||||
console.error('Platform password and an independently onboarded client account are required')
|
||||
process.exit(2)
|
||||
}
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"mode": "report-only",
|
||||
"tools": {
|
||||
"jacocoMavenPlugin": "0.8.12",
|
||||
"spotbugsMavenPlugin": "4.9.8.5"
|
||||
},
|
||||
"topology": {
|
||||
"modules": 22,
|
||||
"codeModules": 21,
|
||||
"services": 15
|
||||
},
|
||||
"openapi": {
|
||||
"documents": 26,
|
||||
"coveredServices": 7,
|
||||
"codeFirstServices": 8
|
||||
},
|
||||
"reports": {
|
||||
"jacocoPresent": 21,
|
||||
"spotbugsPresent": 21,
|
||||
"spotbugsFindings": 780,
|
||||
"spotbugsByPriority": {
|
||||
"1": 1,
|
||||
"2": 779
|
||||
},
|
||||
"spotbugsByCategory": {
|
||||
"BAD_PRACTICE": 10,
|
||||
"CORRECTNESS": 3,
|
||||
"EXPERIMENTAL": 4,
|
||||
"MALICIOUS_CODE": 699,
|
||||
"MT_CORRECTNESS": 2,
|
||||
"PERFORMANCE": 18,
|
||||
"STYLE": 44
|
||||
}
|
||||
},
|
||||
"modules": [
|
||||
{ "module": "common-core", "jacocoCovered": 32, "jacocoTotal": 78, "jacocoPercentage": 41.03, "spotbugsFindings": 6 },
|
||||
{ "module": "common-web", "jacocoCovered": 164, "jacocoTotal": 188, "jacocoPercentage": 87.23, "spotbugsFindings": 1 },
|
||||
{ "module": "common-security", "jacocoCovered": 109, "jacocoTotal": 202, "jacocoPercentage": 53.96, "spotbugsFindings": 6 },
|
||||
{ "module": "common-openapi", "jacocoCovered": 17, "jacocoTotal": 37, "jacocoPercentage": 45.95, "spotbugsFindings": 0 },
|
||||
{ "module": "common-audit", "jacocoCovered": 168, "jacocoTotal": 244, "jacocoPercentage": 68.85, "spotbugsFindings": 8 },
|
||||
{ "module": "common-event-contract", "jacocoCovered": 146, "jacocoTotal": 164, "jacocoPercentage": 89.02, "spotbugsFindings": 18 },
|
||||
{ "module": "gateway", "jacocoCovered": 110, "jacocoTotal": 156, "jacocoPercentage": 70.51, "spotbugsFindings": 4 },
|
||||
{ "module": "gateway-overseas", "jacocoCovered": 134, "jacocoTotal": 272, "jacocoPercentage": 49.26, "spotbugsFindings": 2 },
|
||||
{ "module": "auth", "jacocoCovered": 372, "jacocoTotal": 619, "jacocoPercentage": 60.10, "spotbugsFindings": 5 },
|
||||
{ "module": "iam", "jacocoCovered": 1872, "jacocoTotal": 3032, "jacocoPercentage": 61.74, "spotbugsFindings": 60 },
|
||||
{ "module": "order", "jacocoCovered": 1116, "jacocoTotal": 1605, "jacocoPercentage": 69.53, "spotbugsFindings": 47 },
|
||||
{ "module": "firstleg", "jacocoCovered": 7891, "jacocoTotal": 11592, "jacocoPercentage": 68.07, "spotbugsFindings": 223 },
|
||||
{ "module": "warehouse", "jacocoCovered": 1767, "jacocoTotal": 2922, "jacocoPercentage": 60.47, "spotbugsFindings": 64 },
|
||||
{ "module": "warehouse-overseas-service", "jacocoCovered": 403, "jacocoTotal": 778, "jacocoPercentage": 51.80, "spotbugsFindings": 8 },
|
||||
{ "module": "lastleg-service", "jacocoCovered": 5088, "jacocoTotal": 7832, "jacocoPercentage": 64.96, "spotbugsFindings": 162 },
|
||||
{ "module": "file", "jacocoCovered": 1046, "jacocoTotal": 1614, "jacocoPercentage": 64.81, "spotbugsFindings": 33 },
|
||||
{ "module": "print", "jacocoCovered": 596, "jacocoTotal": 695, "jacocoPercentage": 85.76, "spotbugsFindings": 12 },
|
||||
{ "module": "audit-service", "jacocoCovered": 76, "jacocoTotal": 153, "jacocoPercentage": 49.67, "spotbugsFindings": 0 },
|
||||
{ "module": "finance", "jacocoCovered": 1665, "jacocoTotal": 2236, "jacocoPercentage": 74.46, "spotbugsFindings": 97 },
|
||||
{ "module": "integration-service", "jacocoCovered": 1040, "jacocoTotal": 1420, "jacocoPercentage": 73.24, "spotbugsFindings": 23 },
|
||||
{ "module": "event-bridge-service", "jacocoCovered": 318, "jacocoTotal": 419, "jacocoPercentage": 75.89, "spotbugsFindings": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
export const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)))
|
||||
|
||||
const normalize = (value) => value.replaceAll('\\', '/')
|
||||
const attribute = (source, name) => source.match(new RegExp(`\\b${name}=(?:"([^"]*)"|'([^']*)')`))?.slice(1).find((value) => value !== undefined)
|
||||
|
||||
async function exists(file) {
|
||||
try {
|
||||
await stat(file)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function javaSourceCount(moduleDirectory) {
|
||||
const root = path.join(moduleDirectory, 'src', 'main', 'java')
|
||||
if (!await exists(root)) return 0
|
||||
let count = 0
|
||||
async function walk(directory) {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) await walk(target)
|
||||
else if (entry.isFile() && entry.name.endsWith('.java')) count += 1
|
||||
}
|
||||
}
|
||||
await walk(root)
|
||||
return count
|
||||
}
|
||||
|
||||
async function openApiFiles(moduleDirectory, module) {
|
||||
const root = path.join(moduleDirectory, 'src', 'main', 'resources', 'openapi')
|
||||
if (!await exists(root)) return []
|
||||
const files = []
|
||||
async function walk(directory) {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) await walk(target)
|
||||
else if (entry.isFile() && /\.(?:json|ya?ml)$/i.test(entry.name)) {
|
||||
files.push(normalize(path.join(module, path.relative(moduleDirectory, target))))
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root)
|
||||
return files.sort()
|
||||
}
|
||||
|
||||
export function parseModules(pom) {
|
||||
const block = pom.match(/<modules>([\s\S]*?)<\/modules>/)?.[1] ?? ''
|
||||
return [...block.matchAll(/<module>([^<]+)<\/module>/g)].map((match) => match[1].trim())
|
||||
}
|
||||
|
||||
export function parseServices(serviceMap) {
|
||||
return [...serviceMap.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)].map((match) => match[1]).sort()
|
||||
}
|
||||
|
||||
export function parseJacocoReport(xml) {
|
||||
if (!/<report\b/.test(xml) || !/<\/report>/.test(xml)) throw new Error('not a JaCoCo report')
|
||||
const counters = [...xml.matchAll(/<counter\b([^>]*)\/?\s*>/g)]
|
||||
.map((match) => match[1])
|
||||
.filter((source) => attribute(source, 'type') === 'LINE')
|
||||
if (counters.length === 0) throw new Error('JaCoCo LINE counter is missing')
|
||||
const summary = counters.at(-1)
|
||||
const missed = Number(attribute(summary, 'missed'))
|
||||
const covered = Number(attribute(summary, 'covered'))
|
||||
if (!Number.isInteger(missed) || !Number.isInteger(covered)) throw new Error('invalid JaCoCo LINE counter')
|
||||
const total = missed + covered
|
||||
return { missed, covered, percentage: total === 0 ? null : Number((covered * 100 / total).toFixed(2)) }
|
||||
}
|
||||
|
||||
export function parseSpotbugsReport(xml) {
|
||||
if (!/<BugCollection\b/.test(xml) || !/<\/BugCollection>/.test(xml)) throw new Error('not a SpotBugs report')
|
||||
const findings = [...xml.matchAll(/<BugInstance\b([^>]*)>/g)].map((match) => match[1])
|
||||
const byPriority = {}
|
||||
const byCategory = {}
|
||||
for (const finding of findings) {
|
||||
const priority = attribute(finding, 'priority') ?? 'unknown'
|
||||
const category = attribute(finding, 'category') ?? 'unknown'
|
||||
byPriority[priority] = (byPriority[priority] ?? 0) + 1
|
||||
byCategory[category] = (byCategory[category] ?? 0) + 1
|
||||
}
|
||||
return { findings: findings.length, byPriority, byCategory }
|
||||
}
|
||||
|
||||
async function reportState(file, parser, root) {
|
||||
const relative = normalize(path.relative(root, file))
|
||||
if (!await exists(file)) return { status: 'missing', path: relative }
|
||||
try {
|
||||
return { status: 'valid', path: relative, ...parser(await readFile(file, 'utf8')) }
|
||||
} catch (error) {
|
||||
return { status: 'invalid', path: relative, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildQualitySummary(root = repositoryRoot) {
|
||||
const modules = parseModules(await readFile(path.join(root, 'pom.xml'), 'utf8'))
|
||||
const services = parseServices((await readFile(path.join(root, 'deploy', 'service-map.yml'), 'utf8')).replaceAll('\r\n', '\n'))
|
||||
const moduleRows = []
|
||||
const documents = []
|
||||
for (const module of modules) {
|
||||
const moduleDirectory = path.join(root, module)
|
||||
const sources = await javaSourceCount(moduleDirectory)
|
||||
documents.push(...await openApiFiles(moduleDirectory, module))
|
||||
if (sources === 0) continue
|
||||
const jacoco = await reportState(path.join(moduleDirectory, 'target', 'site', 'jacoco', 'jacoco.xml'), parseJacocoReport, root)
|
||||
const spotbugs = await reportState(path.join(moduleDirectory, 'target', 'spotbugsXml.xml'), parseSpotbugsReport, root)
|
||||
spotbugs.html = await exists(path.join(moduleDirectory, 'target', 'site', 'spotbugs.html'))
|
||||
moduleRows.push({ module, javaSources: sources, jacoco, spotbugs })
|
||||
}
|
||||
const coveredServices = [...new Set(documents.map((file) => file.split('/')[0]))]
|
||||
.filter((module) => services.includes(module)).sort()
|
||||
const codeFirstServices = services.filter((service) => !coveredServices.includes(service))
|
||||
const count = (kind, status) => moduleRows.filter((row) => row[kind].status === status).length
|
||||
return {
|
||||
topology: { modules: modules.length, codeModules: moduleRows.length, services: services.length },
|
||||
openapi: { documents: documents.length, coveredServices, codeFirstServices, files: documents.sort() },
|
||||
jacoco: {
|
||||
present: count('jacoco', 'valid'), invalid: count('jacoco', 'invalid'), missing: count('jacoco', 'missing')
|
||||
},
|
||||
spotbugs: {
|
||||
present: count('spotbugs', 'valid'), invalid: count('spotbugs', 'invalid'),
|
||||
missing: count('spotbugs', 'missing'), findings: moduleRows.reduce((sum, row) => sum + (row.spotbugs.findings ?? 0), 0)
|
||||
},
|
||||
modules: moduleRows
|
||||
}
|
||||
}
|
||||
|
||||
export function requirementFailures(summary, options = {}) {
|
||||
const failures = []
|
||||
for (const row of summary.modules) {
|
||||
if (row.jacoco.status === 'invalid') failures.push(`${row.module}: invalid JaCoCo report (${row.jacoco.error})`)
|
||||
if (row.spotbugs.status === 'invalid') failures.push(`${row.module}: invalid SpotBugs report (${row.spotbugs.error})`)
|
||||
if (row.spotbugs.status === 'valid' && !row.spotbugs.html) failures.push(`${row.module}: SpotBugs HTML report is missing`)
|
||||
if (options.requireJacoco && row.jacoco.status === 'missing') failures.push(`${row.module}: JaCoCo report is missing`)
|
||||
if (options.requireSpotbugs && row.spotbugs.status === 'missing') failures.push(`${row.module}: SpotBugs report is missing`)
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
function printSummary(summary) {
|
||||
process.stdout.write(`Java quality summary: ${summary.topology.modules} modules, ${summary.topology.codeModules} code modules, ${summary.topology.services} services.\n`)
|
||||
process.stdout.write(`OpenAPI: ${summary.openapi.documents} documents, ${summary.openapi.coveredServices.length}/${summary.topology.services} services; gaps: ${summary.openapi.codeFirstServices.join(', ')}.\n`)
|
||||
process.stdout.write(`JaCoCo: ${summary.jacoco.present} valid, ${summary.jacoco.missing} missing, ${summary.jacoco.invalid} invalid.\n`)
|
||||
process.stdout.write(`SpotBugs: ${summary.spotbugs.present} valid, ${summary.spotbugs.missing} missing, ${summary.spotbugs.invalid} invalid, ${summary.spotbugs.findings} findings.\n`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
const rootIndex = args.indexOf('--root')
|
||||
const outputIndex = args.indexOf('--output')
|
||||
const root = rootIndex >= 0 ? path.resolve(args[rootIndex + 1]) : repositoryRoot
|
||||
const summary = await buildQualitySummary(root)
|
||||
printSummary(summary)
|
||||
if (outputIndex >= 0) {
|
||||
const output = path.resolve(args[outputIndex + 1])
|
||||
await mkdir(path.dirname(output), { recursive: true })
|
||||
await writeFile(output, `${JSON.stringify(summary, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
const failures = requirementFailures(summary, {
|
||||
requireJacoco: args.includes('--require-jacoco'),
|
||||
requireSpotbugs: args.includes('--require-spotbugs')
|
||||
})
|
||||
if (failures.length > 0) throw new Error(`Quality report contract failed:\n${failures.map((failure) => `- ${failure}`).join('\n')}`)
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildQualitySummary,
|
||||
parseJacocoReport,
|
||||
parseSpotbugsReport,
|
||||
requirementFailures,
|
||||
repositoryRoot
|
||||
} from './java-quality-summary.mjs'
|
||||
|
||||
test('parses JaCoCo line coverage and SpotBugs findings without quality thresholds', () => {
|
||||
assert.deepEqual(parseJacocoReport('<report><counter type="LINE" missed="3" covered="7"/></report>'), {
|
||||
missed: 3, covered: 7, percentage: 70
|
||||
})
|
||||
assert.deepEqual(parseSpotbugsReport('<BugCollection><BugInstance priority="1" category="CORRECTNESS"/><BugInstance priority="2" category="STYLE"/></BugCollection>'), {
|
||||
findings: 2,
|
||||
byPriority: { 1: 1, 2: 1 },
|
||||
byCategory: { CORRECTNESS: 1, STYLE: 1 }
|
||||
})
|
||||
assert.deepEqual(parseSpotbugsReport("<BugCollection><BugInstance priority='2' category='MALICIOUS_CODE'/></BugCollection>"), {
|
||||
findings: 1,
|
||||
byPriority: { 2: 1 },
|
||||
byCategory: { MALICIOUS_CODE: 1 }
|
||||
})
|
||||
assert.throws(() => parseJacocoReport('<report/>'), /not a JaCoCo report/)
|
||||
assert.throws(() => parseSpotbugsReport('<BugCollection/>'), /not a SpotBugs report/)
|
||||
})
|
||||
|
||||
test('aggregates code modules, report presence and the OpenAPI service matrix', async (t) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'baiyue-quality-summary-'))
|
||||
t.after(() => rm(root, { recursive: true, force: true }))
|
||||
await writeFile(path.join(root, 'pom.xml'), '<modules><module>bom</module><module>alpha</module><module>beta</module></modules>', 'utf8')
|
||||
await mkdir(path.join(root, 'deploy'), { recursive: true })
|
||||
await writeFile(path.join(root, 'deploy', 'service-map.yml'), 'services:\n alpha:\n port: 1\n beta:\n port: 2\n', 'utf8')
|
||||
await mkdir(path.join(root, 'alpha', 'src', 'main', 'java'), { recursive: true })
|
||||
await mkdir(path.join(root, 'beta', 'src', 'main', 'java'), { recursive: true })
|
||||
await writeFile(path.join(root, 'alpha', 'src', 'main', 'java', 'Alpha.java'), 'class Alpha {}', 'utf8')
|
||||
await writeFile(path.join(root, 'beta', 'src', 'main', 'java', 'Beta.java'), 'class Beta {}', 'utf8')
|
||||
await mkdir(path.join(root, 'alpha', 'src', 'main', 'resources', 'openapi'), { recursive: true })
|
||||
await writeFile(path.join(root, 'alpha', 'src', 'main', 'resources', 'openapi', 'alpha.yaml'), 'openapi: 3.0.3\n', 'utf8')
|
||||
await mkdir(path.join(root, 'alpha', 'target', 'site', 'jacoco'), { recursive: true })
|
||||
await writeFile(path.join(root, 'alpha', 'target', 'site', 'jacoco', 'jacoco.xml'), '<report><counter type="LINE" missed="1" covered="9"/></report>', 'utf8')
|
||||
await mkdir(path.join(root, 'alpha', 'target', 'site'), { recursive: true })
|
||||
await writeFile(path.join(root, 'alpha', 'target', 'spotbugsXml.xml'), '<BugCollection><BugInstance priority="2" category="STYLE"/></BugCollection>', 'utf8')
|
||||
await writeFile(path.join(root, 'alpha', 'target', 'site', 'spotbugs.html'), '<html></html>', 'utf8')
|
||||
|
||||
const summary = await buildQualitySummary(root)
|
||||
assert.deepEqual(summary.topology, { modules: 3, codeModules: 2, services: 2 })
|
||||
assert.equal(summary.openapi.documents, 1)
|
||||
assert.deepEqual(summary.openapi.coveredServices, ['alpha'])
|
||||
assert.deepEqual(summary.openapi.codeFirstServices, ['beta'])
|
||||
assert.deepEqual(summary.jacoco, { present: 1, invalid: 0, missing: 1 })
|
||||
assert.deepEqual(summary.spotbugs, { present: 1, invalid: 0, missing: 1, findings: 1 })
|
||||
assert.deepEqual(requirementFailures(summary), [])
|
||||
assert.deepEqual(requirementFailures(summary, { requireJacoco: true, requireSpotbugs: true }), [
|
||||
'beta: JaCoCo report is missing', 'beta: SpotBugs report is missing'
|
||||
])
|
||||
})
|
||||
|
||||
test('repository freezes the current topology and non-blocking aggregate summary wiring', async () => {
|
||||
const summary = await buildQualitySummary(repositoryRoot)
|
||||
const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8'))
|
||||
assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 })
|
||||
assert.equal(summary.openapi.documents, 26)
|
||||
assert.equal(summary.openapi.coveredServices.length, 7)
|
||||
assert.equal(summary.openapi.codeFirstServices.length, 8)
|
||||
assert.equal(baseline.mode, 'report-only')
|
||||
assert.deepEqual(baseline.topology, summary.topology)
|
||||
assert.equal(baseline.modules.length, 21)
|
||||
assert.equal(baseline.modules.reduce((total, row) => total + row.spotbugsFindings, 0), baseline.reports.spotbugsFindings)
|
||||
|
||||
const gitlab = (await readFile(path.join(repositoryRoot, '.gitlab-ci.yml'), 'utf8')).replaceAll('\r\n', '\n')
|
||||
const job = gitlab.match(/^质量摘要:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? ''
|
||||
const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? ''
|
||||
assert.match(job, /^ allow_failure: true$/m)
|
||||
assert.match(job, /java-quality-summary\.test\.mjs/)
|
||||
assert.match(job, /java-quality-summary\.mjs --output target\/java-quality-summary\.json --require-jacoco --require-spotbugs/)
|
||||
assert.match(job, /^ - job: 校验与测试$/m)
|
||||
assert.match(job, /^ - job: 静态分析报告$/m)
|
||||
assert.match(job, /^ - target\/java-quality-summary\.json$/m)
|
||||
assert.doesNotMatch(job, /threshold|ratchet/i)
|
||||
assert.doesNotMatch(packageJob, /^ - 质量摘要$/m)
|
||||
})
|
||||
@@ -62,13 +62,13 @@ const parseCompose = (yaml) => {
|
||||
}))
|
||||
}
|
||||
|
||||
test('root POM freezes 23 modules and service-map freezes 16 executable services', async () => {
|
||||
test('root POM freezes 22 modules and service-map freezes 15 executable services', async () => {
|
||||
const modules = parseRootModules(await read('pom.xml'))
|
||||
const services = parseServiceMap(await read('deploy/service-map.yml'))
|
||||
|
||||
assert.equal(modules.length, 23)
|
||||
assert.equal(new Set(modules).size, 23)
|
||||
assert.equal(services.size, 16)
|
||||
assert.equal(modules.length, 22)
|
||||
assert.equal(new Set(modules).size, 22)
|
||||
assert.equal(services.size, 15)
|
||||
|
||||
const bootModules = []
|
||||
for (const module of modules) {
|
||||
|
||||
@@ -8,18 +8,18 @@ const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.u
|
||||
test('Jenkins pipelines recognize all overseas deployable modules', async () => {
|
||||
const controller = await read('Jenkinsfile')
|
||||
const service = await read('Jenkinsfile.service')
|
||||
for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service']) {
|
||||
for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service']) {
|
||||
assert.match(controller, new RegExp(`'${module}'`))
|
||||
assert.match(service, new RegExp(`'${module}'`))
|
||||
}
|
||||
assert.match(controller, /'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'/)
|
||||
assert.match(controller, /'lastleg-service': 'SERVICE_LASTLEG'/)
|
||||
assert.match(controller, /'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS'/)
|
||||
assert.match(controller, /'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY'/)
|
||||
assert.match(service, /gateway-overseas\/target\/gateway-overseas-1\.0\.0-SNAPSHOT\.jar/)
|
||||
assert.match(service, /lastleg-service\/target\/lastleg-service-1\.0\.0-SNAPSHOT\.jar/)
|
||||
assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/)
|
||||
assert.match(service, /overseas-security-service\/target\/overseas-security-service-1\.0\.0-SNAPSHOT\.jar/)
|
||||
assert.doesNotMatch(controller, /overseas-security-service|SERVICE_OVERSEAS_SECURITY/)
|
||||
assert.doesNotMatch(service, /overseas-security-service/)
|
||||
assert.match(controller, /EVENT_BRIDGE_REGION/)
|
||||
assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/)
|
||||
assert.match(controller, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/)
|
||||
@@ -32,12 +32,11 @@ test('service map freezes unique internal ports and moves event bridge away from
|
||||
assert.match(map, /^ gateway-overseas:[\s\S]*?port: 9300$/m)
|
||||
assert.match(map, /^ lastleg-service:[\s\S]*?port: 9303$/m)
|
||||
assert.match(map, /^ warehouse-overseas-service:[\s\S]*?port: 9305$/m)
|
||||
assert.match(map, /^ overseas-security-service:[\s\S]*?port: 9307$/m)
|
||||
assert.match(map, /^ event-bridge-service:[\s\S]*?port: 9312$/m)
|
||||
assert.match(map, /^ gateway-overseas:[\s\S]*?regions: \[OVERSEAS\]$/m)
|
||||
assert.match(map, /^ lastleg-service:[\s\S]*?regions: \[OVERSEAS\]$/m)
|
||||
assert.match(map, /^ warehouse-overseas-service:[\s\S]*?regions: \[OVERSEAS\]$/m)
|
||||
assert.match(map, /^ overseas-security-service:[\s\S]*?regions: \[OVERSEAS\]$/m)
|
||||
assert.doesNotMatch(map, /overseas-security-service|9307/)
|
||||
assert.match(map, /^ event-bridge-service:[\s\S]*?regions: \[CN, OVERSEAS\]$/m)
|
||||
})
|
||||
|
||||
@@ -50,7 +49,6 @@ test('overseas compose is isolated from CN defaults and disables implicit schema
|
||||
assert.match(compose, /^ gateway-overseas:\s*$/m)
|
||||
assert.match(compose, /^ lastleg-service:\s*$/m)
|
||||
assert.match(compose, /^ warehouse-overseas-service:\s*$/m)
|
||||
assert.match(compose, /^ overseas-security-service:\s*$/m)
|
||||
assert.match(compose, /^ event-bridge-service:\s*$/m)
|
||||
assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312/)
|
||||
assert.match(compose, /INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/)
|
||||
@@ -58,8 +56,9 @@ test('overseas compose is isolated from CN defaults and disables implicit schema
|
||||
assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/)
|
||||
assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/)
|
||||
assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/)
|
||||
assert.match(compose, /OVERSEAS_SECURITY_DB_URL: \$\{OVERSEAS_SECURITY_DB_URL\}/)
|
||||
assert.match(compose, /OVERSEAS_SECURITY_EVENT_CONSUMER_URL:/)
|
||||
assert.match(compose, /LASTLEG_SECURITY_SERVICE_URL: \$\{LASTLEG_SECURITY_SERVICE_URL:-http:\/\/lastleg-service:9303\}/)
|
||||
assert.match(compose, /LASTLEG_SECURITY_EVENT_CONSUMER_URL: \$\{LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events\}/)
|
||||
assert.doesNotMatch(compose, /overseas-security-service|9307|OVERSEAS_SECURITY_DB|OVERSEAS_SECURITY_SERVICE_URL|OVERSEAS_SECURITY_EVENT_CONSUMER_URL/)
|
||||
assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/)
|
||||
assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/)
|
||||
assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/)
|
||||
@@ -72,7 +71,11 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as
|
||||
const cn = await read('config/nacos/cn/event-bridge-service.yml')
|
||||
const overseas = await read('config/nacos/overseas/event-bridge-service.yml')
|
||||
const firstleg = await read('config/nacos/cn/firstleg-service.yml')
|
||||
const iam = await read('config/nacos/cn/iam-service.yml')
|
||||
const iamApplication = await read('iam/src/main/resources/application.yml')
|
||||
const iamClient = await read('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java')
|
||||
const lastleg = await read('config/nacos/overseas/lastleg-service.yml')
|
||||
const gatewayOverseas = await read('config/nacos/overseas/gateway-overseas.yml')
|
||||
const scripts = `${await read('deploy/scripts/deploy-built-service.sh')}\n${await read('deploy/scripts/deploy-services.sh')}`
|
||||
assert.match(cn, /EVENT_CONSUMER_URL:http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events/)
|
||||
assert.match(overseas, /EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/events/)
|
||||
@@ -80,9 +83,17 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as
|
||||
assert.match(cnCompose, /aliases: \[event-bridge-cn\]/)
|
||||
assert.match(overseasCompose, /EVENT_CONSUMER_URL: \$\{EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/events\}/)
|
||||
assert.match(firstleg, /LASTLEG_EVENT_BRIDGE_URL/)
|
||||
assert.match(iamClient, /url = "\$\{baiyue\.security-event\.event-bridge-url:\}"/)
|
||||
assert.match(iam, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/)
|
||||
assert.match(iamApplication, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/)
|
||||
assert.match(cnCompose, /SECURITY_EVENT_BRIDGE_URL: \$\{SECURITY_EVENT_BRIDGE_URL:-http:\/\/event-bridge-overseas:9312\}/)
|
||||
assert.match(overseasCompose, /aliases: \[event-bridge-overseas\]/)
|
||||
assert.match(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/)
|
||||
assert.match(lastleg, /CN_EVENT_BRIDGE_URL:\$\{FIRSTLEG_EVENT_BRIDGE_URL\}/)
|
||||
assert.match(lastleg, /WAREHOUSE_OVERSEAS_SERVICE_URL:http:\/\/warehouse-overseas-service:9305/)
|
||||
assert.match(gatewayOverseas, /LASTLEG_SECURITY_SERVICE_URL:http:\/\/lastleg-service:9303/)
|
||||
assert.match(overseas, /LASTLEG_SECURITY_EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events/)
|
||||
assert.doesNotMatch(`${gatewayOverseas}\n${overseas}`, /overseas-security-service|9307|OVERSEAS_SECURITY_/)
|
||||
assert.match(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/)
|
||||
assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/)
|
||||
assert.match(scripts, /docker-compose\.overseas\.yml/)
|
||||
@@ -120,3 +131,21 @@ test('domestic event bridge SQL converges legacy tenant columns to the customer
|
||||
assert.match(incremental, /bridge_inbox_customer_column = 0 AND @bridge_inbox_tenant_column = 1/)
|
||||
assert.match(incremental, /bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1/)
|
||||
})
|
||||
|
||||
test('overseas unified SQL initializes the isolated Event Bridge database before services start', async () => {
|
||||
const overseas = await read('deploy/sql/海外区域统一增量-MySQL8.sql')
|
||||
assert.match(overseas, /CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas/)
|
||||
assert.match(overseas, /USE baiyue_event_bridge_overseas;/)
|
||||
for (const table of ['bridge_outbox', 'bridge_inbox', 'bridge_delivery_attempt']) {
|
||||
assert.match(overseas, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`))
|
||||
}
|
||||
for (const index of [
|
||||
'uk_bridge_outbox_idempotency', 'idx_bridge_outbox_schedule',
|
||||
'uk_bridge_inbox_idempotency', 'idx_bridge_inbox_consume',
|
||||
'uk_bridge_attempt', 'idx_bridge_attempt_event'
|
||||
]) assert.match(overseas, new RegExp(index))
|
||||
assert.match(overseas, /COUNT\(DISTINCT CONCAT\(table_name, ':', index_name\)\)/)
|
||||
assert.match(overseas, /CALL assert_overseas_event_bridge_schema\(\);/)
|
||||
assert.ok(overseas.indexOf('USE baiyue_event_bridge_overseas;') < overseas.indexOf('USE baiyue_lastleg;'),
|
||||
'overseas Event Bridge schema must be initialized before Lastleg tables')
|
||||
})
|
||||
|
||||
@@ -44,10 +44,47 @@ test('entry accepts a separate overseas Gateway without exposing token state', (
|
||||
assert.equal(options.gateway, 'http://localhost:9000')
|
||||
assert.equal(options.overseasGateway, 'http://localhost:9300')
|
||||
assert.match(source, /this\.overseasToken = login\.overseasAccessToken \|\| ''/)
|
||||
assert.match(source, /this\.refreshToken = login\.refreshToken \|\| ''/)
|
||||
assert.match(source, /this\.overseasExpiresAt = Number\(login\.overseasExpiresIn\)/)
|
||||
assert.match(source, /async refreshSession\(reason\)/)
|
||||
assert.match(source, /refreshToken: this\.refreshToken/)
|
||||
assert.match(source, /const replacement = \{[\s\S]*token: next\.accessToken[\s\S]*refreshToken: next\.refreshToken[\s\S]*overseasToken: next\.overseasAccessToken/)
|
||||
assert.match(source, /module === 'system' && laterOverseasModule/)
|
||||
assert.match(source, /system后新admin海外Token只读探针可用/)
|
||||
assert.match(source, /module\.startsWith\('phase4-overseas-'\)[\s\S]*Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/)
|
||||
assert.match(source, /海外模块 \$\{module\} 执行前Token临近到期/)
|
||||
assert.match(source, /async requestOverseas\(/)
|
||||
assert.match(source, /async requestOverseasBinary\(/)
|
||||
assert.match(source, /async requestOverseasMultipart\(/)
|
||||
assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.overseasToken\]/)
|
||||
assert.match(source, /forbiddenValues = \[this\.options\.password, this\.options\.merchantPassword,[\s\S]*this\.token, this\.refreshToken, this\.overseasToken\]/)
|
||||
})
|
||||
|
||||
test('dual-token refresh replaces the whole session only after a complete response', async () => {
|
||||
const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300',
|
||||
username: 'contract-user', password: 'contract-password', modules: [], output: '' })
|
||||
Object.assign(run, { token: 'old-access', refreshToken: 'old-refresh', overseasToken: 'old-overseas', overseasExpiresAt: 1 })
|
||||
run.request = async (method, path, body, token) => {
|
||||
assert.equal(method, 'POST'); assert.equal(path, '/api/auth/refresh')
|
||||
assert.deepEqual(body, { refreshToken: 'old-refresh' }); assert.equal(token, '')
|
||||
return { method, path, http: 200, body: { code: '200', data: {
|
||||
accessToken: 'new-access', refreshToken: 'new-refresh', overseasAccessToken: 'new-overseas',
|
||||
overseasExpiresIn: 900,
|
||||
} }, requestId: 'refresh-request', traceId: 'refresh-trace' }
|
||||
}
|
||||
assert.equal(await run.refreshSession('contract'), true)
|
||||
assert.equal(run.token, 'new-access')
|
||||
assert.equal(run.refreshToken, 'new-refresh')
|
||||
assert.equal(run.overseasToken, 'new-overseas')
|
||||
assert.ok(run.overseasExpiresAt > Date.now())
|
||||
|
||||
const frozen = { token: run.token, refreshToken: run.refreshToken,
|
||||
overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt }
|
||||
run.request = async (method, path) => ({ method, path, http: 200,
|
||||
body: { code: '200', data: { accessToken: 'partial-only' } },
|
||||
requestId: 'partial-request', traceId: 'partial-trace' })
|
||||
assert.equal(await run.refreshSession('incomplete-contract'), false)
|
||||
assert.deepEqual({ token: run.token, refreshToken: run.refreshToken,
|
||||
overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt }, frozen)
|
||||
})
|
||||
|
||||
test('entry has no built-in environment address or password and requires explicit execution options', () => {
|
||||
@@ -80,8 +117,12 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV
|
||||
assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < modules.indexOf('phase4-overseas-pda'))
|
||||
}
|
||||
assert.match(source, /async ensurePhase4PdaProducerMatrix\(\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS'\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA'\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/)
|
||||
assert.match(source, /runUntilPickupReady\(this\)/)
|
||||
assert.match(source, /carrierScheduleQueryId/)
|
||||
assert.match(source, /readyForPhase4Pda === true/)
|
||||
assert.equal(modules.indexOf('phase4-overseas-pda-operations'), modules.indexOf('phase4-overseas-pda') + 1)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ boolean applyChanges = false
|
||||
String templateName = 'baiyue-firstleg-dev-print'
|
||||
List<String> serviceModules = [
|
||||
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service'
|
||||
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service'
|
||||
]
|
||||
|
||||
Jenkins jenkins = Jenkins.get()
|
||||
|
||||
@@ -64,7 +64,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([
|
||||
feign('firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java',
|
||||
'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '图片上传/作废非安全重放,查询可重复'),
|
||||
feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java',
|
||||
'firstleg-service', 'order-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取订单货物/来源快照'),
|
||||
'firstleg-service', 'order-service', ['REQUEST', 'BACKGROUND'], 'READ', 'SAFE_READ',
|
||||
'在线测量/箱号/排柜读取;Order 审核 Outbox 创建 Firstleg 入库及箱号时也会后台回读订单货物'),
|
||||
feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java',
|
||||
'firstleg-service', 'order-service', ['REQUEST'], 'WRITE', 'IDEMPOTENCY_KEY', 'PDA 结算拆单携带 idempotencyKey/operationId'),
|
||||
feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java',
|
||||
@@ -88,7 +89,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([
|
||||
feign('iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java',
|
||||
'iam-service', 'auth-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '账号查询可重复,写操作按账号/命令业务键收敛'),
|
||||
feign('iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java',
|
||||
'iam-service', 'firstleg-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'),
|
||||
'iam-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'BUSINESS_KEY',
|
||||
'IAM 持久化任务租约投递;初始化/补偿复用稳定 initializationKey,不在客户创建请求内调用'),
|
||||
feign('iam/src/main/java/com/baiyue/iam/client/OverseasStorageFeeClient.java',
|
||||
'iam-service', 'warehouse-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'),
|
||||
feign('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java',
|
||||
@@ -98,6 +100,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([
|
||||
'lastleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '卸柜完成 Outbox 按 eventId 投递'),
|
||||
feign('lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java',
|
||||
'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '报价创建时读取国内合作仓快照'),
|
||||
feign('warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/DomesticWarehouseDirectoryClient.java',
|
||||
'warehouse-overseas-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ',
|
||||
'海外库位、托盘和资源锁写入前读取国内共享收货仓权威目录'),
|
||||
|
||||
feign('order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java',
|
||||
'order-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ_WRITE', 'BUSINESS_KEY',
|
||||
@@ -120,9 +125,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([
|
||||
http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java',
|
||||
'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'),
|
||||
http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java',
|
||||
'event-bridge-service', 'overseas-security-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'),
|
||||
'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'),
|
||||
http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java',
|
||||
'gateway-overseas', 'overseas-security-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'),
|
||||
'gateway-overseas', 'lastleg-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'),
|
||||
http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java',
|
||||
'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'),
|
||||
http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java',
|
||||
|
||||
@@ -13,7 +13,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const serviceModules = [
|
||||
'auth', 'iam', 'order', 'firstleg', 'finance', 'file', 'warehouse',
|
||||
'integration-service', 'print', 'event-bridge-service', 'lastleg-service',
|
||||
'warehouse-overseas-service', 'overseas-security-service'
|
||||
'warehouse-overseas-service'
|
||||
]
|
||||
|
||||
const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`]))
|
||||
@@ -32,7 +32,6 @@ const expectedOnlineEdges = [
|
||||
'firstleg-service->print-service',
|
||||
'firstleg-service->warehouse-service',
|
||||
'iam-service->auth-service',
|
||||
'iam-service->firstleg-service',
|
||||
'iam-service->warehouse-service',
|
||||
'lastleg-service->file-service',
|
||||
'lastleg-service->warehouse-overseas-service',
|
||||
@@ -40,13 +39,13 @@ const expectedOnlineEdges = [
|
||||
'order-service->file-service',
|
||||
'order-service->firstleg-service',
|
||||
'order-service->iam-service',
|
||||
'order-service->warehouse-service'
|
||||
'order-service->warehouse-service',
|
||||
'warehouse-overseas-service->warehouse-service'
|
||||
]
|
||||
|
||||
const expectedBackgroundEdges = [
|
||||
'event-bridge-service->firstleg-service',
|
||||
'event-bridge-service->lastleg-service',
|
||||
'event-bridge-service->overseas-security-service',
|
||||
'file-service->finance-service',
|
||||
'file-service->firstleg-service',
|
||||
'file-service->iam-service',
|
||||
@@ -56,7 +55,9 @@ const expectedBackgroundEdges = [
|
||||
'firstleg-service->event-bridge-service',
|
||||
'firstleg-service->finance-service',
|
||||
'firstleg-service->integration-service',
|
||||
'firstleg-service->order-service',
|
||||
'iam-service->event-bridge-service',
|
||||
'iam-service->firstleg-service',
|
||||
'lastleg-service->event-bridge-service',
|
||||
'order-service->firstleg-service'
|
||||
]
|
||||
@@ -95,11 +96,11 @@ async function discoverFeignEdges() {
|
||||
return clients.sort((left, right) => left.path.localeCompare(right.path))
|
||||
}
|
||||
|
||||
test('all 44 Feign clients have reviewed context, access and idempotency metadata', async () => {
|
||||
test('all 45 Feign clients have reviewed context, access and idempotency metadata', async () => {
|
||||
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN')
|
||||
.map(({ path, source, target }) => ({ path, source, target }))
|
||||
.sort((left, right) => left.path.localeCompare(right.path))
|
||||
assert.equal(reviewed.length, 44)
|
||||
assert.equal(reviewed.length, 45)
|
||||
assert.deepEqual(await discoverFeignEdges(), reviewed)
|
||||
})
|
||||
|
||||
@@ -131,6 +132,20 @@ test('every reviewed synchronous client has a complete and exclusive executable
|
||||
}
|
||||
})
|
||||
|
||||
test('IAM customer dimension Feign is reachable only from the background relay', async () => {
|
||||
const references = []
|
||||
for (const file of await javaFiles(path.join(root, 'iam', 'src', 'main', 'java'))) {
|
||||
const source = await readFile(file, 'utf8')
|
||||
if (source.includes('CustomerDimensionRuleClient')) {
|
||||
references.push(path.relative(root, file).replaceAll('\\', '/'))
|
||||
}
|
||||
}
|
||||
assert.deepEqual(references.sort(), [
|
||||
'iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java',
|
||||
'iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java'
|
||||
])
|
||||
})
|
||||
|
||||
test('all four Lastleg RestClient gateways use explicit bounded transports', async () => {
|
||||
const clients = new Map([
|
||||
['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', 'getUnloadPhotoFile'],
|
||||
@@ -170,13 +185,14 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines',
|
||||
assert.deepEqual(graphEdges('INFRASTRUCTURE'), [
|
||||
'common-audit->audit-service',
|
||||
'gateway-overseas->auth-service',
|
||||
'gateway-overseas->overseas-security-service'
|
||||
'gateway-overseas->lastleg-service'
|
||||
])
|
||||
assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [
|
||||
['auth-service', 'iam-service'],
|
||||
['firstleg-service', 'order-service']
|
||||
])
|
||||
assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [[
|
||||
'auth-service', 'file-service', 'firstleg-service', 'iam-service', 'order-service'
|
||||
]])
|
||||
assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[
|
||||
'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service'
|
||||
'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service', 'order-service'
|
||||
]])
|
||||
assert.deepEqual(stronglyConnectedComponents(graphEdges('ALL')), [[
|
||||
'auth-service', 'event-bridge-service', 'file-service', 'finance-service',
|
||||
@@ -184,10 +200,29 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines',
|
||||
]])
|
||||
})
|
||||
|
||||
test('overseas security edges never become online business dependencies', () => {
|
||||
assert.ok(!graphEdges('REQUEST').some(edge => edge.includes('overseas-security-service')))
|
||||
assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->overseas-security-service'))
|
||||
assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->overseas-security-service'))
|
||||
test('overseas security projection stays in Lastleg infrastructure and background edges', () => {
|
||||
assert.ok(!graphEdges('REQUEST').some(edge => edge === 'gateway-overseas->lastleg-service'))
|
||||
assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->lastleg-service'))
|
||||
assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->lastleg-service'))
|
||||
})
|
||||
|
||||
test('Order and Firstleg remain a bilateral REQUEST SCC until loading-complete timing is frozen', () => {
|
||||
const requestClients = SYNCHRONOUS_CLIENTS.filter(client => client.contexts.includes('REQUEST'))
|
||||
const orderToFirstleg = requestClients.filter(client =>
|
||||
client.source === 'order-service' && client.target === 'firstleg-service')
|
||||
const firstlegToOrder = requestClients.filter(client =>
|
||||
client.source === 'firstleg-service' && client.target === 'order-service')
|
||||
assert.deepEqual(orderToFirstleg.map(client => client.path).sort(), [
|
||||
'order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java',
|
||||
'order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java',
|
||||
'order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java'
|
||||
])
|
||||
assert.deepEqual(firstlegToOrder.map(client => client.path).sort(), [
|
||||
'firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java',
|
||||
'firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java'
|
||||
])
|
||||
assert.ok(stronglyConnectedComponents(graphEdges('REQUEST')).some(component =>
|
||||
component.includes('firstleg-service') && component.includes('order-service')))
|
||||
})
|
||||
|
||||
test('known user entries expose every longest simple REQUEST path over the two-hop budget', () => {
|
||||
@@ -196,37 +231,37 @@ test('known user entries expose every longest simple REQUEST path over the two-h
|
||||
return [entry.service, { hops: paths[0].length - 1, paths: paths.map(path => path.join('->')) }]
|
||||
}))
|
||||
assert.deepEqual(summary, {
|
||||
'auth-service': { hops: 5, paths: [
|
||||
'auth-service->iam-service->firstleg-service->order-service->file-service->integration-service'
|
||||
'auth-service': { hops: 2, paths: [
|
||||
'auth-service->iam-service->warehouse-service'
|
||||
] },
|
||||
'iam-service': { hops: 4, paths: [
|
||||
'iam-service->firstleg-service->order-service->file-service->integration-service'
|
||||
'iam-service': { hops: 1, paths: [
|
||||
'iam-service->auth-service',
|
||||
'iam-service->warehouse-service'
|
||||
] },
|
||||
'order-service': { hops: 4, paths: [
|
||||
'order-service->file-service->iam-service->firstleg-service->print-service',
|
||||
'order-service->file-service->iam-service->firstleg-service->warehouse-service',
|
||||
'order-service->firstleg-service->file-service->iam-service->auth-service',
|
||||
'order-service->firstleg-service->file-service->iam-service->warehouse-service',
|
||||
'order-service->iam-service->firstleg-service->file-service->integration-service'
|
||||
'order-service->firstleg-service->file-service->iam-service->warehouse-service'
|
||||
] },
|
||||
'firstleg-service': { hops: 4, paths: [
|
||||
'firstleg-service->order-service->file-service->iam-service->auth-service',
|
||||
'firstleg-service->order-service->file-service->iam-service->warehouse-service'
|
||||
] },
|
||||
'finance-service': { hops: 5, paths: [
|
||||
'finance-service->file-service->iam-service->firstleg-service->order-service->warehouse-service',
|
||||
'finance-service->firstleg-service->order-service->file-service->iam-service->auth-service',
|
||||
'finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service',
|
||||
'finance-service->iam-service->firstleg-service->order-service->file-service->integration-service'
|
||||
'finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service'
|
||||
] },
|
||||
'file-service': { hops: 4, paths: [
|
||||
'file-service->iam-service->firstleg-service->order-service->warehouse-service'
|
||||
'file-service': { hops: 2, paths: [
|
||||
'file-service->iam-service->auth-service',
|
||||
'file-service->iam-service->warehouse-service'
|
||||
] },
|
||||
'lastleg-service': { hops: 5, paths: [
|
||||
'lastleg-service->file-service->iam-service->firstleg-service->order-service->warehouse-service'
|
||||
'lastleg-service': { hops: 3, paths: [
|
||||
'lastleg-service->file-service->iam-service->auth-service',
|
||||
'lastleg-service->file-service->iam-service->warehouse-service'
|
||||
] },
|
||||
'warehouse-service': { hops: 0, paths: ['warehouse-service'] },
|
||||
'warehouse-overseas-service': { hops: 0, paths: ['warehouse-overseas-service'] }
|
||||
'warehouse-overseas-service': { hops: 1, paths: [
|
||||
'warehouse-overseas-service->warehouse-service'
|
||||
] }
|
||||
})
|
||||
for (const entry of USER_ENTRIES) {
|
||||
const result = summary[entry.service]
|
||||
|
||||
@@ -57,6 +57,8 @@ test('Java builds use the shared builder pool and serialize incremental target d
|
||||
assert.doesNotMatch(directDeployScript, /docker save "\$IMAGE"/)
|
||||
assert.match(directDeployScript, /up -d --no-deps --pull never/)
|
||||
assert.doesNotMatch(directDeployScript, /--remove-orphans/)
|
||||
assert.match(directDeployScript, /--project-name "\$PROJECT_NAME"/)
|
||||
assert.doesNotMatch(directDeployScript, /overseas-security-service|remove_legacy_overseas_security_container/)
|
||||
assert.match(directDeployScript, /--pull never/)
|
||||
})
|
||||
|
||||
@@ -85,13 +87,12 @@ test('development and test deployment locks are acquired before target executors
|
||||
assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation')
|
||||
})
|
||||
|
||||
test('development exposes gateways for remote acceptance and keeps internal services on loopback', () => {
|
||||
test('development publishes every service on loopback without changing test defaults', () => {
|
||||
assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PORT:-9000\}:9000/)
|
||||
assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_OVERSEAS_PORT:-9300\}:9300/)
|
||||
assert.match(directDeployScript, /gateway\|gateway-overseas\) PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/)
|
||||
assert.match(directDeployScript, /\*\) PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/)
|
||||
assert.match(directDeployScript, /gateway\|gateway-overseas\) export PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/)
|
||||
assert.match(directDeployScript, /\*\) export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/)
|
||||
assert.match(directDeployScript, /if \[ "\$DEPLOY_ENV" = "development" \]; then\s+PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/)
|
||||
assert.match(directDeployScript, /if \[ "\$deploy_env" = "development" \]; then\s+export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/)
|
||||
assert.doesNotMatch(directDeployScript, /gateway\|gateway-overseas\) (?:export )?PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/)
|
||||
})
|
||||
|
||||
test('parallel image publications isolate registry credentials per Jenkins job', () => {
|
||||
|
||||
Reference in New Issue
Block a user