906 lines
56 KiB
JavaScript
906 lines
56 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// Test environment administrator / 测试环境管理员
|
|
// Username: platform_admin
|
|
// Password: ChangeMe123!
|
|
|
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
import { dirname, resolve } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import process from 'node:process'
|
|
|
|
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
|
|
const DEFAULT_USERNAME = 'platform_admin'
|
|
const DEFAULT_PASSWORD = 'ChangeMe123!'
|
|
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-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
|
|
|
|
export function normalizeModuleOrder(modules) {
|
|
const normalized = [...modules]
|
|
const manualGroups = ['order-inbound-status-fixtures', 'manual-state-loading-pda', 'manual-state-ocean-trucking']
|
|
const firstManualGroupIndex = normalized.findIndex((name) => manualGroups.includes(name))
|
|
if (!normalized.includes('manual-state-matrix') && firstManualGroupIndex >= 0) {
|
|
normalized.splice(firstManualGroupIndex, 0, 'manual-state-matrix')
|
|
}
|
|
const manualMatrixIndex = normalized.indexOf('manual-state-matrix')
|
|
if (manualMatrixIndex >= 0) {
|
|
for (const group of manualGroups) {
|
|
const internalGroupIndex = normalized.indexOf(group)
|
|
if (internalGroupIndex >= 0) normalized.splice(internalGroupIndex, 1)
|
|
}
|
|
const prerequisites = [
|
|
'merchant-qualification-fixture',
|
|
'phase3-container-loading',
|
|
'phase3-loading',
|
|
'ocean-bookings',
|
|
'phase3-customs',
|
|
'phase3-loading-finalize',
|
|
]
|
|
for (const prerequisite of prerequisites) {
|
|
if (!normalized.includes(prerequisite)) {
|
|
normalized.splice(normalized.indexOf('manual-state-matrix'), 0, prerequisite)
|
|
}
|
|
}
|
|
}
|
|
if (normalized.includes('phase3-order-splitting')) {
|
|
const prerequisites = ['merchant-qualification-fixture', 'phase3-container-loading', 'phase3-loading']
|
|
for (const prerequisite of prerequisites) {
|
|
if (!normalized.includes(prerequisite)) {
|
|
normalized.splice(normalized.indexOf('phase3-order-splitting'), 0, prerequisite)
|
|
}
|
|
}
|
|
const splitIndex = normalized.indexOf('phase3-order-splitting')
|
|
const loadingIndex = normalized.indexOf('phase3-loading')
|
|
if (splitIndex < loadingIndex) {
|
|
normalized.splice(splitIndex, 1)
|
|
normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-order-splitting')
|
|
}
|
|
}
|
|
const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']
|
|
const firstFinanceIndex = normalized.findIndex((name) => financeChain.includes(name))
|
|
if (firstFinanceIndex >= 0) {
|
|
const prerequisites = ['merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading']
|
|
const missing = prerequisites.filter((name) => !normalized.includes(name))
|
|
if (missing.length > 0) normalized.splice(firstFinanceIndex, 0, ...missing)
|
|
}
|
|
const financeDownloadsIndex = normalized.indexOf('finance-downloads')
|
|
if (financeDownloadsIndex >= 0 && financeChain.some((name) => normalized.includes(name))) {
|
|
for (const prerequisite of financeChain) {
|
|
if (!normalized.includes(prerequisite)) normalized.splice(normalized.indexOf('finance-downloads'), 0, prerequisite)
|
|
}
|
|
const completeFinanceChain = [...financeChain, 'finance-downloads']
|
|
const firstChainIndex = normalized.findIndex((name) => completeFinanceChain.includes(name))
|
|
let predecessor = firstChainIndex > 0 ? normalized[firstChainIndex - 1] : null
|
|
if (!predecessor) predecessor = ['phase3-container-loading', 'inbound-measurement', 'orders', 'merchant-qualification-fixture']
|
|
.find((name) => normalized.includes(name)) || null
|
|
const withoutChain = normalized.filter((name) => !completeFinanceChain.includes(name))
|
|
const insertionIndex = predecessor ? withoutChain.indexOf(predecessor) + 1 : withoutChain.length
|
|
withoutChain.splice(insertionIndex, 0, ...completeFinanceChain)
|
|
normalized.splice(0, normalized.length, ...withoutChain)
|
|
}
|
|
if (normalized.includes('fee-items')) {
|
|
const prerequisites = ['merchant-qualification-fixture', 'orders']
|
|
const feeItemsIndex = normalized.indexOf('fee-items')
|
|
const ordered = prerequisites.every((name, index) => {
|
|
const prerequisiteIndex = normalized.indexOf(name)
|
|
const previousIndex = index === 0 ? -1 : normalized.indexOf(prerequisites[index - 1])
|
|
return prerequisiteIndex > previousIndex && prerequisiteIndex < feeItemsIndex
|
|
})
|
|
if (!ordered) {
|
|
const requested = normalized.filter((name) => !prerequisites.includes(name))
|
|
requested.splice(requested.indexOf('fee-items'), 0, ...prerequisites)
|
|
normalized.splice(0, normalized.length, ...requested)
|
|
}
|
|
}
|
|
if (normalized.includes('inbound-fees')) {
|
|
const prerequisites = ['merchant-qualification-fixture', 'orders', 'inbound-measurement']
|
|
const targetIndex = normalized.indexOf('inbound-fees')
|
|
const missing = prerequisites.filter((name) => !normalized.includes(name))
|
|
if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing)
|
|
}
|
|
const loadingIndex = normalized.indexOf('phase3-loading')
|
|
const overviewIndex = normalized.indexOf('phase3-container-overview')
|
|
if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized
|
|
normalized.splice(overviewIndex, 1)
|
|
normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview')
|
|
return normalized
|
|
}
|
|
|
|
const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES)
|
|
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
|
|
const CUSTOMER_FIELDS = [
|
|
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
|
|
'contactName', 'contactPhone', 'contactEmail', 'contactWechat',
|
|
'servicePackage', 'salesOwnerName', 'paymentTermDays', 'enterpriseType', 'establishedDate',
|
|
'registeredCapital', 'registeredCapitalCurrency', 'registeredAddress', 'internalRemark',
|
|
'createdAt',
|
|
]
|
|
const BOX_DEFAULTS = [
|
|
{ boxType: 'SMALL', dimensions: ['30.000', '20.000', '20.000'], remark: '标准小箱' },
|
|
{ boxType: 'MEDIUM', dimensions: ['40.000', '30.000', '30.000'], remark: '标准中箱' },
|
|
{ boxType: 'LARGE', dimensions: ['50.000', '40.000', '40.000'], remark: '标准大箱' },
|
|
{ boxType: 'EXTRA_LARGE', dimensions: ['60.000', '50.000', '50.000'], remark: '特大箱' },
|
|
]
|
|
|
|
function usage() {
|
|
console.log(`后台管理测试环境 HTTP API 验收
|
|
|
|
测试管理员: ${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD}
|
|
|
|
用法:
|
|
node scripts/admin-test-env-acceptance.mjs [options]
|
|
|
|
选项:
|
|
--gateway <url> Gateway 地址,默认 ${DEFAULT_GATEWAY}
|
|
--username <name> 管理员账号,默认 ${DEFAULT_USERNAME}
|
|
--password <value> 管理员密码,默认使用上方测试密码
|
|
--modules <names> 逗号分隔,默认 ${DEFAULT_MODULES.join(',')}
|
|
--output <path> JSON 报告路径,默认 reports/
|
|
--help 显示帮助
|
|
|
|
环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`)
|
|
}
|
|
|
|
export function parseArguments(argv) {
|
|
if (argv.includes('--help')) { usage(); process.exit(0) }
|
|
const values = new Map()
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index]
|
|
if (!argument.startsWith('--')) throw new Error(`无法识别的参数: ${argument}`)
|
|
const separator = argument.indexOf('=')
|
|
const name = separator >= 0 ? argument.slice(2, separator) : argument.slice(2)
|
|
const inline = separator >= 0 ? argument.slice(separator + 1) : undefined
|
|
if (inline !== undefined) values.set(name, inline)
|
|
else {
|
|
const next = argv[index + 1]
|
|
if (!next || next.startsWith('--')) throw new Error(`参数 --${name} 缺少值`)
|
|
values.set(name, next); index += 1
|
|
}
|
|
}
|
|
return {
|
|
gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || DEFAULT_GATEWAY).replace(/\/$/, ''),
|
|
username: values.get('username') || process.env.ADMIN_TEST_USERNAME || DEFAULT_USERNAME,
|
|
password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || DEFAULT_PASSWORD,
|
|
modules: normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)),
|
|
output: values.get('output') || '',
|
|
}
|
|
}
|
|
|
|
function own(value, field) { return value !== null && typeof value === 'object' && Object.hasOwn(value, field) }
|
|
function hasFields(value, fields) { return fields.every((field) => own(value, field)) }
|
|
function numberEquals(actual, expected) { return Math.abs(Number(actual) - Number(expected)) < 0.0005 }
|
|
function halfUp3(value) { return Math.round((Number(value) + Number.EPSILON) * 1000) / 1000 }
|
|
function derived(length, width, height, edge) {
|
|
const outerLength = Number(length) + Number(edge) * 2
|
|
const outerWidth = Number(width) + Number(edge) * 2
|
|
const outerHeight = Number(height) + Number(edge) * 2
|
|
return { outerLength, outerWidth, outerHeight, volume: halfUp3(outerLength * outerWidth * outerHeight / 1_000_000) }
|
|
}
|
|
|
|
export class AcceptanceRun {
|
|
constructor(options) {
|
|
this.options = options
|
|
this.marker = `ADMIN-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}`
|
|
this.startedAt = new Date().toISOString()
|
|
this.steps = []
|
|
this.modules = []
|
|
this.scenarios = []
|
|
this.createdResources = {}
|
|
this.deploymentBlockers = []
|
|
this.coverageGaps = []
|
|
this.context = {}
|
|
this.token = ''
|
|
this.fatal = null
|
|
}
|
|
|
|
async request(method, path, body = undefined, token = this.token, extraHeaders = {}) {
|
|
const headers = { Accept: 'application/json', ...extraHeaders }
|
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8'
|
|
try {
|
|
const response = await fetch(`${this.options.gateway}${path}`, {
|
|
method, headers, body: body === undefined ? undefined : JSON.stringify(body),
|
|
signal: AbortSignal.timeout(30_000),
|
|
})
|
|
const content = await response.text()
|
|
let payload = null
|
|
try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is recorded below. */ }
|
|
return {
|
|
method, path, http: response.status, body: payload,
|
|
raw: payload === null ? content.slice(0, 500) : null,
|
|
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
|
traceId: payload?.traceId || response.headers.get('x-trace-id'),
|
|
}
|
|
} catch (error) {
|
|
return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
|
|
}
|
|
}
|
|
|
|
async requestMultipart(method, path, file, token = this.token, extraHeaders = {}) {
|
|
const form = file instanceof FormData ? file : new FormData()
|
|
if (!(file instanceof FormData)) {
|
|
if (!file || file.content === undefined) throw new TypeError('multipart file requires content')
|
|
const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content)
|
|
form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin')
|
|
}
|
|
return this.requestWithBody(method, path, form, token, extraHeaders)
|
|
}
|
|
|
|
async requestWithBody(method, path, body, token = this.token, extraHeaders = {}) {
|
|
const headers = { Accept: 'application/json', ...extraHeaders }
|
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
if (!(body instanceof FormData) && body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8'
|
|
try {
|
|
const response = await fetch(`${this.options.gateway}${path}`, {
|
|
method, headers, body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
|
|
signal: AbortSignal.timeout(60_000),
|
|
})
|
|
const content = await response.text(); let payload = null
|
|
try { payload = content ? JSON.parse(content) : null } catch { /* bounded raw response */ }
|
|
return { method, path, http: response.status, body: payload,
|
|
raw: payload === null ? content.slice(0, 500) : null,
|
|
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
|
traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
|
} catch (error) {
|
|
return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
|
|
}
|
|
}
|
|
|
|
async requestBinary(method, path) {
|
|
const headers = { Accept: '*/*', Authorization: `Bearer ${this.token}` }
|
|
try {
|
|
const response = await fetch(`${this.options.gateway}${path}`, { method, headers, signal: AbortSignal.timeout(120_000) })
|
|
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
const contentType = response.headers.get('content-type') || ''
|
|
let payload = null
|
|
if (contentType.toLowerCase().includes('json')) {
|
|
try { payload = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* Invalid envelope remains binary evidence. */ }
|
|
}
|
|
return {
|
|
method, path, http: response.status, body: payload, bytes, contentType,
|
|
raw: payload ? null : `${bytes.length} bytes (${contentType || 'unknown'})`,
|
|
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
|
traceId: payload?.traceId || response.headers.get('x-trace-id'),
|
|
}
|
|
} catch (error) {
|
|
return { method, path, http: 0, body: null, bytes: new Uint8Array(), contentType: '', raw: String(error), requestId: null, traceId: null }
|
|
}
|
|
}
|
|
|
|
addStep(module, name, response, passed, evidence = '', options = {}) {
|
|
const step = {
|
|
module, scenario: options.scenario || null, name, passed: Boolean(passed),
|
|
deploymentBlocker: Boolean(options.deploymentBlocker && !passed),
|
|
request: response ? { method: response.method, path: response.path } : null,
|
|
http: response?.http ?? null,
|
|
code: response?.body?.code === undefined ? null : String(response.body.code),
|
|
message: response?.body?.message || response?.raw || null,
|
|
requestId: response?.requestId || null, traceId: response?.traceId || null,
|
|
evidence, checkedAt: new Date().toISOString(),
|
|
}
|
|
this.steps.push(step)
|
|
if (step.deploymentBlocker) this.deploymentBlockers.push({ module, scenario: step.scenario, name, evidence, code: step.code, requestId: step.requestId, traceId: step.traceId })
|
|
console.log(`[${step.passed ? 'PASS' : 'FAIL'}] [${module}${step.scenario ? `/${step.scenario}` : ''}] ${name}${evidence ? ` - ${evidence}` : ''}`)
|
|
return step.passed
|
|
}
|
|
|
|
addCoverageGap(module, scenario, name, reason, category = 'not-implemented', metadata = {}) {
|
|
this.coverageGaps.push({ module, scenario, name, reason, category, ...metadata })
|
|
}
|
|
|
|
apiSuccess(module, name, response, evidence = '', options = {}) {
|
|
const passed = response.http === 200 && String(response.body?.code) === '200'
|
|
this.addStep(module, name, response, passed, evidence, options)
|
|
return passed ? response.body.data : null
|
|
}
|
|
|
|
assert(module, name, response, condition, evidence, options = {}) {
|
|
return this.addStep(module, name, response, Boolean(condition), evidence, options)
|
|
}
|
|
|
|
expectConflict(module, name, response, scenario) {
|
|
const passed = response.http === 200 && ['A0409', '409'].includes(String(response.body?.code))
|
|
this.addStep(module, name, response, passed, `HTTP=${response.http}, code=${response.body?.code ?? 'missing'}`, { scenario, deploymentBlocker: true })
|
|
return passed
|
|
}
|
|
|
|
async executeModule(name, handler) {
|
|
const startedAt = new Date().toISOString(); const start = this.steps.length; let fatal = null
|
|
try { await handler() } catch (error) {
|
|
fatal = error instanceof Error ? error.message : String(error)
|
|
this.addStep(name, '模块执行', null, false, fatal)
|
|
}
|
|
const steps = this.steps.slice(start)
|
|
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 })
|
|
}
|
|
|
|
async findAvailablePhone(module = 'customers') {
|
|
const seed = BigInt(Date.now() % 10_000_000_000)
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
const phone = `1${((seed + BigInt(attempt)) % 10_000_000_000n).toString().padStart(10, '0')}`
|
|
const response = await this.request('GET', `/api/platform/v1/iam/tenants/contact-phone-availability?contactPhone=${phone}`)
|
|
if (this.apiSuccess(module, '检查测试联系电话可用性', response, `attempt=${attempt + 1}`) === true) return phone
|
|
}
|
|
throw new Error('连续 20 个测试联系电话均不可用')
|
|
}
|
|
|
|
async runCustomerDimensionRules(tenantId) {
|
|
const module = 'customers'; const scenario = 'customer-dimension-rules'; const start = this.steps.length
|
|
const options = { scenario, deploymentBlocker: true }
|
|
const path = `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${tenantId}`
|
|
const initialResponse = await this.request('GET', path)
|
|
const group = this.apiSuccess(module, '新客户尺寸规则可查询', initialResponse, `tenantId=${tenantId}`, options)
|
|
const rules = Array.isArray(group?.rules) ? group.rules : []
|
|
const orderedTypes = rules.map((rule) => rule.boxType)
|
|
this.assert(module, '固定四箱规顺序完整且无重复', initialResponse,
|
|
rules.length === 4 && orderedTypes.join(',') === BOX_DEFAULTS.map((item) => item.boxType).join(',') && new Set(orderedTypes).size === 4,
|
|
`count=${rules.length}, order=${orderedTypes.join(',')}`, options)
|
|
|
|
const defaultsCorrect = BOX_DEFAULTS.every((expected, index) => {
|
|
const rule = rules[index]
|
|
return rule?.boxType === expected.boxType
|
|
&& numberEquals(rule.innerLengthCm, expected.dimensions[0])
|
|
&& numberEquals(rule.innerWidthCm, expected.dimensions[1])
|
|
&& numberEquals(rule.innerHeightCm, expected.dimensions[2])
|
|
&& numberEquals(rule.edgeAdditionCm, '2.000') && rule.remark === expected.remark
|
|
})
|
|
this.assert(module, '四箱规默认内径、加边和备注正确', initialResponse, defaultsCorrect,
|
|
defaultsCorrect ? 'defaults=matched' : '默认尺寸、edge 或备注不匹配', options)
|
|
|
|
const identityCorrect = typeof group?.tenantId === 'string' && group.tenantId === tenantId
|
|
&& rules.length === 4 && rules.every((rule) => typeof rule.id === 'string' && Number(rule.version) === 0)
|
|
this.assert(module, '客户 ID、规则 ID 为字符串且初始版本为0', initialResponse, identityCorrect,
|
|
`groupTenantIdType=${typeof group?.tenantId}, rows=${rules.map((r) => `${typeof r.id}/v${r.version}`).join(',')}`, options)
|
|
|
|
const initialDerivedCorrect = rules.length === 4 && rules.every((rule) => {
|
|
const value = derived(rule.innerLengthCm, rule.innerWidthCm, rule.innerHeightCm, rule.edgeAdditionCm)
|
|
return numberEquals(rule.outerLengthCm, value.outerLength) && numberEquals(rule.outerWidthCm, value.outerWidth)
|
|
&& numberEquals(rule.outerHeightCm, value.outerHeight) && numberEquals(rule.volumeCbm, value.volume)
|
|
})
|
|
this.assert(module, '默认规则外径与 HALF_UP 三位 CBM 正确', initialResponse, initialDerivedCorrect,
|
|
`derivedRows=${rules.length}`, options)
|
|
|
|
if (rules.length === 4) {
|
|
const updatePayload = { rules: rules.map((rule, index) => ({
|
|
boxType: rule.boxType,
|
|
innerLengthCm: Number((Number(rule.innerLengthCm) + index + 0.125).toFixed(3)),
|
|
innerWidthCm: Number((Number(rule.innerWidthCm) + index + 0.250).toFixed(3)),
|
|
innerHeightCm: Number((Number(rule.innerHeightCm) + index + 0.375).toFixed(3)),
|
|
edgeAdditionCm: Number((2.125 + index * 0.125).toFixed(3)),
|
|
remark: `${this.marker}-${rule.boxType}`,
|
|
version: Number(rule.version),
|
|
})) }
|
|
const allowed = ['boxType', 'innerLengthCm', 'innerWidthCm', 'innerHeightCm', 'edgeAdditionCm', 'remark', 'version']
|
|
this.assert(module, 'PUT 每行只发送约定7字段并整组保存', initialResponse,
|
|
updatePayload.rules.length === 4 && updatePayload.rules.every((row) => Object.keys(row).join(',') === allowed.join(',')),
|
|
`fields=${Object.keys(updatePayload.rules[0]).join(',')}`, options)
|
|
|
|
const updateResponse = await this.request('PUT', path, updatePayload)
|
|
const updatedGroup = this.apiSuccess(module, '一次 PUT 保存四条三位小数规则', updateResponse, 'rows=4', options)
|
|
const updatedRules = Array.isArray(updatedGroup?.rules) ? updatedGroup.rules : []
|
|
const updatedCorrect = updatedRules.length === 4 && updatePayload.rules.every((expected, index) => {
|
|
const actual = updatedRules[index]; const value = derived(expected.innerLengthCm, expected.innerWidthCm, expected.innerHeightCm, expected.edgeAdditionCm)
|
|
return actual?.boxType === expected.boxType && Number(actual.version) === expected.version + 1
|
|
&& numberEquals(actual.innerLengthCm, expected.innerLengthCm) && numberEquals(actual.innerWidthCm, expected.innerWidthCm)
|
|
&& numberEquals(actual.innerHeightCm, expected.innerHeightCm) && numberEquals(actual.edgeAdditionCm, expected.edgeAdditionCm)
|
|
&& numberEquals(actual.outerLengthCm, value.outerLength) && numberEquals(actual.outerWidthCm, value.outerWidth)
|
|
&& numberEquals(actual.outerHeightCm, value.outerHeight) && numberEquals(actual.volumeCbm, value.volume)
|
|
&& actual.remark === expected.remark
|
|
})
|
|
this.assert(module, '修改后四行版本+1且派生外径/CBM正确', updateResponse, updatedCorrect,
|
|
`versions=${updatedRules.map((r) => r.version).join(',')}`, options)
|
|
|
|
const stalePayload = { rules: updatePayload.rules.map((rule) => ({
|
|
...rule,
|
|
innerLengthCm: Number((Number(rule.innerLengthCm) + 9).toFixed(3)),
|
|
})) }
|
|
const staleResponse = await this.request('PUT', path, stalePayload)
|
|
this.expectConflict(module, '旧 version 重放 PUT 返回冲突', staleResponse, scenario)
|
|
const afterConflictResponse = await this.request('GET', path)
|
|
const afterConflict = this.apiSuccess(module, '冲突后重新查询四条规则', afterConflictResponse, '', options)
|
|
const afterRules = Array.isArray(afterConflict?.rules) ? afterConflict.rules : []
|
|
const stableSnapshot = (rule) => JSON.stringify({
|
|
id: String(rule.id), boxType: rule.boxType,
|
|
innerLengthCm: Number(rule.innerLengthCm), innerWidthCm: Number(rule.innerWidthCm),
|
|
innerHeightCm: Number(rule.innerHeightCm), edgeAdditionCm: Number(rule.edgeAdditionCm),
|
|
outerLengthCm: Number(rule.outerLengthCm), outerWidthCm: Number(rule.outerWidthCm),
|
|
outerHeightCm: Number(rule.outerHeightCm), volumeCbm: Number(rule.volumeCbm),
|
|
remark: rule.remark ?? null, version: Number(rule.version), updatedAt: rule.updatedAt ?? null,
|
|
})
|
|
const atomic = afterRules.length === 4 && updatedRules.length === 4
|
|
&& afterRules.every((rule, index) => stableSnapshot(rule) === stableSnapshot(updatedRules[index]))
|
|
this.assert(module, '冲突请求未造成四行部分更新', afterConflictResponse, atomic,
|
|
`unchangedRows=${atomic ? 4 : 0}`, options)
|
|
|
|
const currentByType = new Map(afterRules.map((rule) => [rule.boxType, rule]))
|
|
const restorePayload = { rules: rules.map((original) => ({
|
|
boxType: original.boxType,
|
|
innerLengthCm: Number(original.innerLengthCm),
|
|
innerWidthCm: Number(original.innerWidthCm),
|
|
innerHeightCm: Number(original.innerHeightCm),
|
|
edgeAdditionCm: Number(original.edgeAdditionCm),
|
|
remark: original.remark ?? null,
|
|
version: Number(currentByType.get(original.boxType)?.version),
|
|
})) }
|
|
const restorable = restorePayload.rules.length === 4
|
|
&& restorePayload.rules.every((rule) => Number.isInteger(rule.version) && rule.version >= 0)
|
|
if (restorable) {
|
|
const restoreResponse = await this.request('PUT', path, restorePayload)
|
|
const restoredGroup = this.apiSuccess(module, '恢复本轮修改前的四箱规配置', restoreResponse,
|
|
'restore=original-values', options)
|
|
const restoredRules = Array.isArray(restoredGroup?.rules) ? restoredGroup.rules : []
|
|
const restored = restoredRules.length === 4 && rules.every((original, index) => {
|
|
const actual = restoredRules[index]
|
|
return actual?.boxType === original.boxType
|
|
&& numberEquals(actual.innerLengthCm, original.innerLengthCm)
|
|
&& numberEquals(actual.innerWidthCm, original.innerWidthCm)
|
|
&& numberEquals(actual.innerHeightCm, original.innerHeightCm)
|
|
&& numberEquals(actual.edgeAdditionCm, original.edgeAdditionCm)
|
|
&& actual.remark === original.remark
|
|
&& Number(actual.version) === restorePayload.rules[index].version + 1
|
|
})
|
|
this.assert(module, '尺寸规则恢复后回显与初始值一致', restoreResponse, restored,
|
|
`restoredRows=${restored ? 4 : 0}`, options)
|
|
} else {
|
|
this.assert(module, '恢复本轮修改前的四箱规配置', afterConflictResponse, false,
|
|
'冲突后未返回完整四行及有效版本,无法安全恢复', options)
|
|
}
|
|
} else {
|
|
this.assert(module, '尺寸规则编辑与并发冲突场景', initialResponse, false, '默认规则不是完整四条,禁止降级跳过', options)
|
|
}
|
|
|
|
const steps = this.steps.slice(start)
|
|
this.scenarios.push({ name: scenario, module, success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker), passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
|
|
}
|
|
|
|
async runCustomers() {
|
|
const module = 'customers'; const phone = await this.findAvailablePhone(module); const suffix = this.marker.slice(-12)
|
|
const payload = {
|
|
tenantName: `后台客户验收-${suffix}`, legalName: `后台客户验收有限公司-${suffix}`,
|
|
legalRepresentative: '接口验收法人', contactName: '接口验收联系人', contactPhone: phone,
|
|
contactEmail: `admin-api-${suffix.toLowerCase()}@example.com`, servicePackage: 'API验收套餐',
|
|
contactWechat: `api_${suffix.toLowerCase()}`, salesOwnerName: '接口验收销售', paymentTermDays: 30,
|
|
enterpriseType: 'LIMITED_COMPANY', establishedDate: '2020-01-02', registeredCapital: 5000000,
|
|
registeredCapitalCurrency: 'CNY', registeredAddress: '深圳市南山区接口验收地址', internalRemark: this.marker,
|
|
}
|
|
const createResponse = await this.request('POST', '/api/platform/v1/iam/tenants', payload)
|
|
const createData = this.apiSuccess(module, '创建客户', createResponse)
|
|
if (!createData) throw new Error('创建客户失败,后续客户模块无法继续')
|
|
const customer = createData.tenant || createData.customer || createData
|
|
const tenantId = String(customer.id || createData.tenantId || '')
|
|
if (!tenantId) throw new Error('创建响应未返回客户 ID')
|
|
this.context.customerId = tenantId
|
|
this.createdResources.customer = { tenantId, marker: this.marker, contactPhone: phone }
|
|
this.assert(module, '创建响应客户字段完整', createResponse, hasFields(customer, CUSTOMER_FIELDS), `missing=${CUSTOMER_FIELDS.filter((f) => !own(customer, f)).join(',') || 'none'}`)
|
|
this.assert(module, '新客户基础字段正确', createResponse, customer.tenantName === payload.tenantName && customer.contactPhone === phone && customer.status === 'PROFILE_REQUIRED', `tenantId=${tenantId}, status=${customer.status}`)
|
|
const ownerProvisioningOptions = { scenario: 'customer-owner-provisioning', deploymentBlocker: true }
|
|
this.assert(module, '创建客户生成初始密码', createResponse,
|
|
typeof createData.temporaryPassword === 'string' && createData.temporaryPassword.length >= 8
|
|
&& /[A-Za-z]/.test(createData.temporaryPassword) && /\d/.test(createData.temporaryPassword),
|
|
`generated=${typeof createData.temporaryPassword === 'string'}, length=${createData.temporaryPassword?.length || 0}`,
|
|
ownerProvisioningOptions)
|
|
this.assert(module, '管理员用户名默认使用联系电话', createResponse,
|
|
createData.administratorUsername === phone,
|
|
`matches=${createData.administratorUsername === phone}`,
|
|
ownerProvisioningOptions)
|
|
this.assert(module, '待完善客户的审核字段保持为空', createResponse,
|
|
customer.unifiedSocialCreditCode == null && customer.approvedAt == null,
|
|
`creditCode=${customer.unifiedSocialCreditCode ?? 'omitted'}, approvedAt=${customer.approvedAt ?? 'omitted'}`)
|
|
|
|
await this.runCustomerDimensionRules(tenantId)
|
|
|
|
const listResponse = await this.request('GET', `/api/platform/v1/iam/tenants?page=1&size=20&keyword=${encodeURIComponent(payload.tenantName)}`)
|
|
const page = this.apiSuccess(module, '查询客户列表', listResponse); const listed = page?.records?.find((item) => String(item.id) === tenantId)
|
|
this.assert(module, '列表包含新客户且字段完整', listResponse, listed && hasFields(listed, CUSTOMER_FIELDS), `found=${Boolean(listed)}, missing=${listed ? CUSTOMER_FIELDS.filter((f) => !own(listed, f)).join(',') || 'none' : 'all'}`)
|
|
const detailPath = `/api/platform/v1/iam/tenants/${tenantId}`
|
|
const detailResponse = await this.request('GET', detailPath); const detail = this.apiSuccess(module, '查询客户详情', detailResponse)
|
|
this.assert(module, '客户详情基本信息正确', detailResponse, detail && detail.tenantName === payload.tenantName && detail.contactPhone === phone && detail.internalRemark === payload.internalRemark, `tenantId=${tenantId}`)
|
|
const editedPayload = { ...payload, tenantName: `${payload.tenantName}-已编辑`, contactName: '接口验收联系人-已编辑', contactEmail: `edited-${payload.contactEmail}`, paymentTermDays: 45, internalRemark: `${this.marker}-EDITED` }
|
|
const editResponse = await this.request('PUT', detailPath, editedPayload); const edited = this.apiSuccess(module, '编辑客户', editResponse)
|
|
this.assert(module, '客户编辑正确回显', editResponse, edited?.tenantName === editedPayload.tenantName && edited?.contactEmail === editedPayload.contactEmail && Number(edited?.paymentTermDays) === 45, `tenantId=${tenantId}`)
|
|
const persistedResponse = await this.request('GET', detailPath); const persisted = this.apiSuccess(module, '回查客户编辑结果', persistedResponse)
|
|
this.assert(module, '客户编辑已持久化', persistedResponse, persisted?.tenantName === editedPayload.tenantName && persisted?.internalRemark === editedPayload.internalRemark, `tenantId=${tenantId}`)
|
|
let customerAuditPage = null
|
|
let customerAuditResponse = null
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
customerAuditResponse = await this.request('GET', `/api/platform/v1/audit/logs?accountId=${tenantId}&eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId=${tenantId}&page=1&size=20`)
|
|
customerAuditPage = customerAuditResponse.http === 200 && String(customerAuditResponse.body?.code) === '200' ? customerAuditResponse.body?.data : null
|
|
if (customerAuditPage?.records?.some((item) => item.eventType === 'PLATFORM_TENANT_UPDATE' && String(item.accountId) === tenantId)) break
|
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
}
|
|
const customerAudit = this.apiSuccess(module, '按客户查询操作日志', customerAuditResponse)
|
|
const customerAuditRow = customerAudit?.records?.find((item) => item.eventType === 'PLATFORM_TENANT_UPDATE' && String(item.accountId) === tenantId)
|
|
let auditDiagnostic = 'event=missing'
|
|
if (!customerAuditRow) {
|
|
const unscopedResponse = await this.request('GET', `/api/platform/v1/audit/logs?eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId=${tenantId}&page=1&size=20`)
|
|
const unscopedPage = unscopedResponse.http === 200 && String(unscopedResponse.body?.code) === '200'
|
|
? unscopedResponse.body?.data : null
|
|
const misplaced = unscopedPage?.records?.find((item) => item.eventType === 'PLATFORM_TENANT_UPDATE')
|
|
auditDiagnostic = misplaced
|
|
? `eventAccountId=${misplaced.accountId ?? 'missing'}, expectedAccountId=${tenantId}`
|
|
: `event=not-delivered, editRequestId=${editResponse.requestId || 'missing'}, editTraceId=${editResponse.traceId || 'missing'}`
|
|
}
|
|
this.assert(module, '客户操作日志包含本轮编辑事件', customerAuditResponse, Boolean(customerAuditRow),
|
|
`tenantId=${tenantId}, ${customerAuditRow ? `event=${customerAuditRow.eventType}` : auditDiagnostic}`)
|
|
if (customerAuditRow?.id) {
|
|
const auditDetailResponse = await this.request('GET', `/api/platform/v1/audit/logs/${customerAuditRow.id}`)
|
|
const auditDetail = this.apiSuccess(module, '查询客户操作日志详情', auditDetailResponse)
|
|
const snapshotsAreJson = ['beforeData', 'afterData'].every((field) => {
|
|
if (typeof auditDetail?.[field] !== 'string') return false
|
|
try { JSON.parse(auditDetail[field]); return true } catch { return false }
|
|
})
|
|
const snapshotText = `${auditDetail?.beforeData || ''}${auditDetail?.afterData || ''}`
|
|
const snapshotsAreRedacted = !/(password|authorization|accessToken|refreshToken)\s*[\"']?\s*:/i.test(snapshotText)
|
|
this.assert(module, '操作日志详情返回脱敏 JSON 快照', auditDetailResponse,
|
|
snapshotsAreJson && snapshotsAreRedacted && String(auditDetail.accountId) === tenantId && String(auditDetail.resourceId) === tenantId,
|
|
`accountId=${auditDetail?.accountId ?? 'missing'}, resourceId=${auditDetail?.resourceId ?? 'missing'}, snapshotsAreJson=${snapshotsAreJson}, snapshotsAreRedacted=${snapshotsAreRedacted}`)
|
|
}
|
|
const qualificationPackage = new FormData()
|
|
const qualificationImage = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
|
|
qualificationPackage.append('businessLicense', new Blob([qualificationImage], { type: 'image/png' }), `business-license-${suffix}.png`)
|
|
qualificationPackage.append('legalPersonId', new Blob([qualificationImage], { type: 'image/png' }), `legal-person-id-${suffix}.png`)
|
|
qualificationPackage.append('unifiedSocialCreditCode', `91440301${suffix.replace(/[^0-9A-Z]/gi, '').toUpperCase().padStart(10, '0').slice(-10)}`)
|
|
const packageResponse = await this.requestWithBody('POST', `/api/platform/v1/files/qualifications/${tenantId}/required-package`, qualificationPackage)
|
|
this.apiSuccess(module, '上传审核所需客户资质包', packageResponse, `tenantId=${tenantId}`)
|
|
const qualificationResponse = await this.request('GET', `${detailPath}/qualifications`); const qualifications = this.apiSuccess(module, '业务概况查询资质', qualificationResponse)
|
|
const requiredQualifications = Array.isArray(qualifications)
|
|
? qualifications.filter((item) => ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'].includes(item.qualificationType)) : []
|
|
this.assert(module, '审核资质列表包含营业执照和法人身份证', qualificationResponse,
|
|
requiredQualifications.length === 2 && requiredQualifications.every((item) => typeof item.documentUrl === 'string' && item.documentUrl.length > 0),
|
|
`types=${requiredQualifications.map((item) => item.qualificationType).join(',') || 'missing'}`)
|
|
for (const qualification of requiredQualifications) {
|
|
const fileName = decodeURIComponent(new URL(qualification.documentUrl, 'http://acceptance.local').pathname.split('/').pop() || '')
|
|
const downloadResponse = await this.requestBinary('GET', `/api/platform/v1/files/qualifications/${tenantId}/${encodeURIComponent(fileName)}`)
|
|
this.assert(module, `审核人员可下载${qualification.qualificationType}`, downloadResponse,
|
|
downloadResponse.http === 200 && downloadResponse.bytes.length > 0 && /^image\/|application\/pdf/.test(downloadResponse.contentType),
|
|
`file=${fileName}, bytes=${downloadResponse.bytes.length}, contentType=${downloadResponse.contentType || 'missing'}`)
|
|
}
|
|
const memberResponse = await this.request('GET', `${detailPath}/members`); const members = this.apiSuccess(module, '业务概况查询成员', memberResponse)
|
|
const owner = members?.find?.((item) => item.memberType === 'OWNER')
|
|
this.assert(module, '联系电话账号是企业管理员', memberResponse,
|
|
owner?.username === phone,
|
|
`ownerFound=${Boolean(owner)}, matches=${owner?.username === phone}`,
|
|
ownerProvisioningOptions)
|
|
const countResponse = await this.request('GET', `/api/platform/v1/orders/first-leg/tenant-counts?tenantIds=${tenantId}`); const counts = this.apiSuccess(module, '业务概况查询运单数量', countResponse); const count = counts?.find?.((item) => String(item.tenantId) === tenantId)
|
|
this.assert(module, '业务概况返回当前客户运单数', countResponse, count && Number.isFinite(Number(count.orderCount)), `orderCount=${count?.orderCount ?? 'missing'}`)
|
|
const ordersResponse = await this.request('GET', `/api/platform/v1/orders/first-leg?tenantId=${tenantId}&page=1&size=10`); const orders = this.apiSuccess(module, '查询客户运单记录', ordersResponse)
|
|
this.assert(module, '运单记录分页结构正确', ordersResponse, Array.isArray(orders?.records) && Number.isFinite(Number(orders?.total)), `records=${orders?.records?.length ?? 'n/a'}, total=${orders?.total ?? 'n/a'}`)
|
|
const archiveOptions = { scenario: 'customer-draft-archive', deploymentBlocker: true }
|
|
const archiveResponse = await this.request('POST', `${detailPath}/archive`)
|
|
const archived = this.apiSuccess(module, '归档测试客户', archiveResponse, `tenantId=${tenantId}`, archiveOptions)
|
|
if (archived) {
|
|
this.assert(module, '测试客户归档后处于 ARCHIVED 状态', archiveResponse,
|
|
archived.status === 'ARCHIVED', `status=${archived.status ?? 'missing'}`, archiveOptions)
|
|
}
|
|
}
|
|
|
|
async prepareInboundMeasurementFixture() {
|
|
const originalMarker = this.marker
|
|
const fixtureMarker = `${originalMarker}-MOJIA`
|
|
const createdKeys = ['orders', 'ocrInbound', 'testInbound']
|
|
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
|
|
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
|
|
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
|
|
}]))
|
|
const contextSnapshot = new Map(contextKeys.map((key) => [key, {
|
|
present: Object.hasOwn(this.context, key), value: this.context[key],
|
|
}]))
|
|
|
|
this.marker = fixtureMarker
|
|
const extension = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url))
|
|
await this.executeModule('orders-inbound-measurement-fixture', () => extension.run(this))
|
|
const fixture = this.createdResources.orders
|
|
this.createdResources.orchestrationFixtures = {
|
|
...(this.createdResources.orchestrationFixtures || {}),
|
|
inboundMeasurement: fixture ? {
|
|
marker: fixtureMarker,
|
|
tenantId: fixture.tenantId,
|
|
orderId: fixture.orderId,
|
|
inboundId: fixture.inboundId,
|
|
} : { marker: fixtureMarker, failed: true },
|
|
}
|
|
|
|
return () => {
|
|
this.marker = originalMarker
|
|
for (const [key, snapshot] of createdSnapshot) {
|
|
if (snapshot.present) this.createdResources[key] = snapshot.value
|
|
else delete this.createdResources[key]
|
|
}
|
|
for (const [key, snapshot] of contextSnapshot) {
|
|
if (snapshot.present) this.context[key] = snapshot.value
|
|
else delete this.context[key]
|
|
}
|
|
}
|
|
}
|
|
|
|
async preparePhase3ContainerFixture() {
|
|
const originalMarker = this.marker
|
|
// Isolate order idempotency, then publish the fixture under the run marker.
|
|
const fixtureMarker = `${originalMarker}-CAB`
|
|
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
|
|
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
|
|
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
|
|
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
|
|
}]))
|
|
const contextSnapshot = new Map(contextKeys.map((key) => [key, {
|
|
present: Object.hasOwn(this.context, key), value: this.context[key],
|
|
}]))
|
|
|
|
this.marker = fixtureMarker
|
|
const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url))
|
|
await this.executeModule('orders-phase3-container-fixture', () => orders.run(this))
|
|
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
|
|
await this.executeModule('inbound-measurement-phase3-container-fixture', () => measurement.run(this))
|
|
const fixture = this.createdResources.inboundMeasurement
|
|
if (!fixture?.inboundId || fixture.finalStatus !== 'APPROVED' || fixture.processStatus !== 'WAIT_SPLIT') {
|
|
throw new Error(`独立柜次测量 fixture 未进入 WAIT_SPLIT,禁止回退使用 WAIT_PRICING 订单: processStatus=${fixture?.processStatus || 'missing'}, measurementStatus=${fixture?.finalStatus || 'missing'}`)
|
|
}
|
|
this.createdResources.orchestrationFixtures = {
|
|
...(this.createdResources.orchestrationFixtures || {}),
|
|
phase3Container: fixture ? {
|
|
marker: originalMarker,
|
|
sourceMarker: fixtureMarker,
|
|
tenantId: fixture.tenantId,
|
|
orderId: fixture.orderId || this.createdResources.orders?.orderId,
|
|
inboundId: fixture.inboundId,
|
|
} : { marker: fixtureMarker, failed: true },
|
|
}
|
|
|
|
return () => {
|
|
this.marker = originalMarker
|
|
for (const [key, snapshot] of createdSnapshot) {
|
|
if (snapshot.present) this.createdResources[key] = snapshot.value
|
|
else delete this.createdResources[key]
|
|
}
|
|
for (const [key, snapshot] of contextSnapshot) {
|
|
if (snapshot.present) this.context[key] = snapshot.value
|
|
else delete this.context[key]
|
|
}
|
|
}
|
|
}
|
|
|
|
async preparePhase3LoadingFixture() {
|
|
const originalMarker = this.marker
|
|
const fixtureMarker = `${originalMarker}-LOAD`
|
|
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
|
|
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound',
|
|
'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount']
|
|
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
|
|
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
|
|
}]))
|
|
const contextSnapshot = new Map(contextKeys.map((key) => [key, {
|
|
present: Object.hasOwn(this.context, key), value: this.context[key],
|
|
}]))
|
|
|
|
this.marker = fixtureMarker
|
|
const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url))
|
|
await this.executeModule('orders-phase3-loading-fixture', () => orders.run(this))
|
|
const fixtureInbound = this.createdResources.orders
|
|
const warehouseId = String(fixtureInbound?.receivingWarehouseId || '')
|
|
this.assert('phase3-loading-fixture', 'A0 隔离使用本轮订单收货仓', null,
|
|
Boolean(warehouseId), `warehouseId=${warehouseId || 'missing'}`,
|
|
{ scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true })
|
|
const disabledLocations = await this.disableAvailableLocationsForA0(warehouseId)
|
|
this.context.phase3LoadingRequireSharedOverflow = true
|
|
try {
|
|
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
|
|
await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this))
|
|
} finally {
|
|
await this.restoreAvailableLocationsAfterA0(warehouseId, disabledLocations)
|
|
}
|
|
const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders
|
|
const loadingFixture = fixture ? {
|
|
marker: originalMarker,
|
|
sourceMarker: fixtureMarker,
|
|
tenantId: fixture.tenantId,
|
|
orderId: fixture.orderId || this.createdResources.orders?.orderId,
|
|
inboundId: fixture.inboundId,
|
|
} : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true }
|
|
const incrementalMarker = `${originalMarker}-LOAD-INC`
|
|
this.marker = incrementalMarker
|
|
this.context.phase3LoadingRequireSharedOverflow = false
|
|
this.context.phase3LoadingIncrementalPackageCount = 2
|
|
await this.executeModule('orders-phase3-loading-incremental-fixture', () => orders.run(this))
|
|
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
|
|
await this.executeModule('inbound-measurement-phase3-loading-incremental-fixture', () => measurement.run(this))
|
|
const incrementalSource = this.createdResources.inboundMeasurement || this.createdResources.orders
|
|
const incrementalFixture = incrementalSource ? {
|
|
marker: originalMarker,
|
|
sourceMarker: incrementalMarker,
|
|
tenantId: incrementalSource.tenantId,
|
|
orderId: incrementalSource.orderId,
|
|
inboundId: incrementalSource.inboundId,
|
|
} : { marker: originalMarker, failed: true }
|
|
this.createdResources.orchestrationFixtures = {
|
|
...(this.createdResources.orchestrationFixtures || {}),
|
|
phase3Loading: loadingFixture,
|
|
phase3LoadingIncremental: incrementalFixture,
|
|
inboundMeasurement: loadingFixture,
|
|
}
|
|
this.marker = originalMarker
|
|
|
|
return () => {
|
|
this.marker = originalMarker
|
|
for (const [key, snapshot] of createdSnapshot) {
|
|
if (snapshot.present) this.createdResources[key] = snapshot.value
|
|
else delete this.createdResources[key]
|
|
}
|
|
for (const [key, snapshot] of contextSnapshot) {
|
|
if (snapshot.present) this.context[key] = snapshot.value
|
|
else delete this.context[key]
|
|
}
|
|
}
|
|
}
|
|
|
|
async disableAvailableLocationsForA0(warehouseId) {
|
|
const module = 'phase3-loading-fixture'
|
|
const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
|
|
if (!warehouseId) {
|
|
this.assert(module, 'A0 隔离收货仓 ID 完整', null, false, 'warehouseId=missing', options)
|
|
return []
|
|
}
|
|
const pageResponse = await this.request('GET',
|
|
`/api/platform/v1/locations?tenantId=0&page=1&size=200&warehouseId=${encodeURIComponent(warehouseId)}&status=AVAILABLE`)
|
|
const page = this.apiSuccess(module, '查询 A0 隔离前全部可用普通库位', pageResponse, '', options)
|
|
const records = Array.isArray(page?.records) ? page.records : []
|
|
const locations = records.filter((item) => item.sharedOverflow !== true)
|
|
this.assert(module, 'A0 隔离库位分页完整', pageResponse,
|
|
Number(page?.total) === records.length && records.length < 200,
|
|
`total=${page?.total ?? 'missing'}, records=${records.length}, normal=${locations.length}`, options)
|
|
const disabled = []
|
|
const skippedOccupied = []
|
|
for (const location of locations) {
|
|
const response = await this.request('PATCH',
|
|
`/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`,
|
|
{ status: 'DISABLED', version: Number(location.version) })
|
|
const success = response?.http === 200 && String(response?.body?.code) === '200'
|
|
const occupiedConflict = response?.http === 200
|
|
&& String(response?.body?.code) === 'A0409'
|
|
&& String(response?.body?.message) === '预占或占用中的库位不能停用'
|
|
this.assert(module, `临时停用 A0 隔离库位 ${location.locationCode}`, response, success || occupiedConflict,
|
|
`locationId=${location.id}, beforeVersion=${location.version}${occupiedConflict ? ', skipped=active-resource' : ''}`, options)
|
|
if (success) disabled.push({ id: String(location.id), code: location.locationCode, version: Number(location.version) + 1 })
|
|
if (occupiedConflict) skippedOccupied.push({ id: String(location.id), code: location.locationCode })
|
|
}
|
|
const availableResponse = await this.request('GET',
|
|
`/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`)
|
|
const available = this.apiSuccess(module, '停用后回查收货仓无可用普通库位', availableResponse, '', options)
|
|
this.assert(module, '测量前 A0 溢出条件成立', availableResponse,
|
|
Array.isArray(available) && available.length === 0,
|
|
`disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, available=${Array.isArray(available) ? available.length : 'missing'}`, options)
|
|
return disabled
|
|
}
|
|
|
|
async restoreAvailableLocationsAfterA0(warehouseId, disabledLocations) {
|
|
const module = 'phase3-loading-fixture'
|
|
const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
|
|
for (const location of disabledLocations) {
|
|
const response = await this.request('PATCH',
|
|
`/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`,
|
|
{ status: 'AVAILABLE', version: location.version })
|
|
this.assert(module, `恢复 A0 隔离库位 ${location.code}`, response,
|
|
response?.http === 200 && String(response?.body?.code) === '200',
|
|
`locationId=${location.id}, restoreVersion=${location.version}`, options)
|
|
}
|
|
this.createdResources.phase3LoadingA0Isolation = {
|
|
warehouseId, restored: disabledLocations.map((item) => item.id), marker: this.marker,
|
|
}
|
|
}
|
|
|
|
async run() {
|
|
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
|
|
const handlers = { customers: () => this.runCustomers() }
|
|
for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) {
|
|
const module = this.options.modules[moduleIndex]
|
|
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' })
|
|
continue
|
|
}
|
|
let restoreFixture = null
|
|
const ocrIndex = this.options.modules.indexOf('inbound-weight-ocr')
|
|
if (module === 'inbound-measurement' && ocrIndex >= 0 && ocrIndex < moduleIndex) {
|
|
try {
|
|
restoreFixture = await this.prepareInboundMeasurementFixture()
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error)
|
|
this.addStep('orders-inbound-measurement-fixture', '创建独立测量入库单', null, false, reason,
|
|
{ scenario: 'inbound-resource-isolation' })
|
|
}
|
|
}
|
|
if (module === 'phase3-container-loading') {
|
|
try {
|
|
restoreFixture = await this.preparePhase3ContainerFixture()
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error)
|
|
this.addStep('phase3-container-fixture', '创建独立柜次级入库单', null, false, reason,
|
|
{ scenario: 'phase3-container-resource-isolation' })
|
|
}
|
|
}
|
|
if (module === 'phase3-loading') {
|
|
try {
|
|
restoreFixture = await this.preparePhase3LoadingFixture()
|
|
} catch (error) {
|
|
this.marker = this.marker.replace(/-LOAD$/, '')
|
|
const reason = error instanceof Error ? error.message : String(error)
|
|
this.addStep('phase3-loading-fixture', '创建独立 WAIT_SPLIT 装柜入库单', null, false, reason,
|
|
{ scenario: 'phase3-loading-resource-isolation' })
|
|
}
|
|
}
|
|
let handler = handlers[module]
|
|
if (!handler) {
|
|
try {
|
|
const extension = await import(new URL(`./admin-test-env-modules/${module}.mjs`, import.meta.url))
|
|
if (typeof extension.run !== 'function') throw new Error('模块必须导出 async function run(context)')
|
|
handler = () => extension.run(this)
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error)
|
|
this.addStep(module, '模块注册检查', null, false, `无法加载模块: ${reason}`)
|
|
this.modules.push({ name: module, success: false, passed: 0, failed: 1, fatal: reason })
|
|
if (restoreFixture) restoreFixture()
|
|
continue
|
|
}
|
|
}
|
|
try {
|
|
await this.executeModule(module, handler)
|
|
} finally {
|
|
if (restoreFixture) restoreFixture()
|
|
}
|
|
}
|
|
}
|
|
const reportSurface = JSON.stringify({
|
|
steps: this.steps, modules: this.modules, scenarios: this.scenarios,
|
|
deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps,
|
|
createdResources: this.createdResources,
|
|
})
|
|
const forbiddenValues = [this.options.password, this.token].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,
|
|
/\beyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
|
|
/data:(?:image|application)\/[A-Za-z0-9.+-]+;base64,/i,
|
|
/"(?:bytes|base64|ocrText|ocrRawText)"\s*:/i,
|
|
]
|
|
const sensitive = forbiddenValues.some((value) => reportSurface.includes(value))
|
|
|| sensitivePatterns.some((pattern) => pattern.test(reportSurface))
|
|
this.addStep('report-security', '报告敏感信息扫描', null, !sensitive,
|
|
sensitive ? '检测到密码、Token、二进制/base64 或 OCR 原文字段' : '未发现密码、Token、文件二进制/base64 或 OCR 原文字段')
|
|
|
|
const failed = this.steps.filter((step) => !step.passed).length
|
|
return {
|
|
reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker,
|
|
gateway: this.options.gateway, generatedAt: new Date().toISOString(), startedAt: this.startedAt,
|
|
success: this.fatal === null && failed === 0, fatal: this.fatal,
|
|
summary: { total: this.steps.length, passed: this.steps.length - failed, failed },
|
|
modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers,
|
|
coverageGaps: this.coverageGaps,
|
|
createdResources: this.createdResources,
|
|
excluded: [
|
|
{ scope: 'dashboard', reason: '首页为静态演示数据,不调用后端 API' },
|
|
{ scope: 'hidden-prototypes', reason: '隐藏原型页面尚无已确认后端契约' },
|
|
],
|
|
securityNote: '报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。',
|
|
steps: this.steps,
|
|
}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArguments(process.argv.slice(2))
|
|
const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14)
|
|
const outputPath = resolve(options.output || `reports/admin-test-env-acceptance-${timestamp}.json`)
|
|
const report = await new AcceptanceRun(options).run()
|
|
await mkdir(dirname(outputPath), { recursive: true })
|
|
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
|
console.log(`REPORT=${outputPath}`)
|
|
console.log(`RESULT=${report.success ? 'PASS' : 'FAIL'} total=${report.summary.total} passed=${report.summary.passed} failed=${report.summary.failed}`)
|
|
process.exitCode = report.success ? 0 : 1
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 })
|
|
}
|