This commit is contained in:
2026-08-08 10:11:04 +08:00
parent f3c2bafec8
commit fc14856ea3
64 changed files with 50932 additions and 30 deletions
+448
View File
@@ -0,0 +1,448 @@
#!/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 process from 'node:process'
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
const DEFAULT_USERNAME = 'platform_admin'
const DEFAULT_PASSWORD = 'ChangeMe123!'
const DEFAULT_MODULES = ['customers', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement']
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`)
}
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: (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) }
}
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.context = {}
this.token = ''
this.fatal = null
}
async request(method, path, body = undefined, token = this.token) {
const headers = { Accept: 'application/json' }
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 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
}
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}`)
const qualificationResponse = await this.request('GET', `${detailPath}/qualifications`); const qualifications = this.apiSuccess(module, '业务概况查询资质', qualificationResponse)
this.assert(module, '资质概况结构正确', qualificationResponse, Array.isArray(qualifications), `count=${Array.isArray(qualifications) ? qualifications.length : 'n/a'}`)
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, '测试客户归档后处于停用状态', archiveResponse,
archived.status === 'DISABLED', `status=${archived.status ?? 'missing'}`, archiveOptions)
}
}
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 (const module of this.options.modules) {
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 })
continue
}
}
await this.executeModule(module, handler)
}
}
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,
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
}
main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 })
@@ -0,0 +1,21 @@
# Admin HTTP 验收模块协作表
所有对话共用入口 `../admin-test-env-acceptance.mjs`,原则上只修改自己负责的模块文件。
| 对话 ID | 主题 | 负责文件 / 场景 |
|---|---|---|
| 当前总控对话 | Admin 全量脚本 | 总入口、`system.mjs`、`master-data.mjs`、`downloads.mjs`、最终集成与全量报告 |
| `019fdbd3-f24f-7903-9124-de79c3ba37d0` | 客户尺寸规则 | 总入口内 `customer-dimension-rules` 子场景;不得改其他 customers 流程 |
| `019fdb7d-8e29-7c40-bda6-5a29dcb37971` | OCR 扫描 | `inbound-weight-ocr.mjs` |
| `019fdac1-ae04-7032-bac0-968497ea54ad` | 墨家测量 | `inbound-measurement.mjs` |
| `019fd5af-2e85-74b0-94e5-98c1b9ea4d3e` | 船期价格表 | `pricing.mjs` |
| `019fdbb1-ca56-7263-8914-a29d96cc5177` | 客户端创建订单 | `orders.mjs`,负责创建隔离测试订单并补 Admin 状态流程 |
## 统一交付要求
1. 模块导出 `async function run(context)`,接口见 `README.md`。
2. 测试数据必须带 `context.marker`,不得修改无关既有订单或主数据。
3. 模块完成后运行 `node --check scripts/admin-test-env-modules/<module>.mjs`。
4. 再运行 `node scripts/admin-test-env-acceptance.mjs --modules <module>`,报告写入 `reports/`。
5. 报告不得包含密码、Token、图片二进制或 OCR 原文全文。
6. 未部署的新契约必须记录 `deploymentBlocker`,不得跳过或模拟成功。
+37
View File
@@ -0,0 +1,37 @@
# Admin 测试环境 HTTP 模块
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
例如 `--modules system,inbound-weight-ocr` 会依次加载:
- `system.mjs`
- `inbound-weight-ocr.mjs`
模块只需导出:
```js
export async function run(context) {
const response = await context.request('GET', '/api/platform/v1/example')
const data = context.apiSuccess('example', '查询示例', response)
context.assert('example', '示例结构正确', response, Array.isArray(data), `count=${data?.length ?? 'n/a'}`)
}
```
## 可用上下文
- `request(method, path, body?)`:JSON HTTP 请求,自动使用平台 Token。
- `requestBinary(method, path)`:文件下载,不把二进制写入报告。
- `apiSuccess(module, name, response, evidence?, options?)`:断言 HTTP 200 + body code `200`,成功时返回 data。
- `assert(module, name, response, condition, evidence, options?)`:业务断言。
- `expectConflict(module, name, response, scenario)`:断言并发冲突。
- `addStep(...)`:写入统一步骤报告。
- `marker`:本轮唯一测试标记。
- `createdResources`:登记测试资源 ID。
- `context`:模块间共享少量 ID;模块应尽量独立。
## 约束
- 不记录密码、Token、图片二进制或 OCR 原文全文。
- 测试数据必须带 `context.marker`;可删除的清理,不能删除的最终停用。
- 缺少已确认契约时记录失败或 `deploymentBlocker`,不得模拟成功。
- 一个模块只修改自己的 `.mjs`,不要修改其他模块文件。
@@ -0,0 +1,7 @@
export async function run(context) {
const module = 'replace-with-module-name'
const response = await context.request('GET', '/api/platform/v1/replace-with-real-api')
const data = context.apiSuccess(module, '查询模块列表', response)
context.assert(module, '列表结构正确', response, Array.isArray(data?.records),
`records=${data?.records?.length ?? 'n/a'}`)
}
@@ -0,0 +1,43 @@
function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) }
function isXlsx(response) {
return response.http === 200 && response.bytes?.length > 4 && response.bytes[0] === 0x50 && response.bytes[1] === 0x4b
}
export async function run(context) {
const module = 'downloads'
const createResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', {
exportType: 'CUSTOMER_LIST', format: 'XLSX', taskName: `Admin全量验收-${context.marker}`,
parameters: { keyword: context.marker },
})
const task = context.apiSuccess(module, '创建客户列表导出任务', createResponse)
const taskId = task?.id == null ? '' : String(task.id)
context.assert(module, '导出任务创建字段正确', createResponse,
taskId && task?.exportType === 'CUSTOMER_LIST' && task?.format === 'XLSX',
`taskId=${taskId || 'missing'}, status=${task?.status ?? 'missing'}`)
if (!taskId) throw new Error('导出任务创建失败')
context.createdResources.exportTask = { id: taskId }
const listResponse = await context.request('GET', '/api/platform/v1/files/export-tasks?page=1&size=20&exportType=CUSTOMER_LIST')
const page = context.apiSuccess(module, '查询下载中心任务列表', listResponse)
context.assert(module, '下载中心列表包含新任务', listResponse,
page?.records?.some((item) => String(item.id) === taskId), `records=${page?.records?.length ?? 'n/a'}`)
let latestResponse = createResponse
let latest = task
for (let attempt = 0; attempt < 30 && !['SUCCEEDED', 'FAILED', 'EXPIRED'].includes(latest?.status); attempt += 1) {
await wait(2000)
latestResponse = await context.request('GET', `/api/platform/v1/files/export-tasks/${encodeURIComponent(taskId)}`)
if (latestResponse.http === 200 && String(latestResponse.body?.code) === '200') latest = latestResponse.body.data
}
context.addStep(module, '轮询导出任务直到终态', latestResponse,
latest?.status === 'SUCCEEDED', `status=${latest?.status ?? 'missing'}, processedRows=${latest?.processedRows ?? 'n/a'}`)
if (latest?.status !== 'SUCCEEDED') return
const detailResponse = await context.request('GET', `/api/platform/v1/files/export-tasks/${encodeURIComponent(taskId)}`)
const detail = context.apiSuccess(module, '查询导出任务详情', detailResponse)
context.assert(module, '导出详情行数和文件元数据正确', detailResponse,
String(detail?.id) === taskId && Number.isFinite(Number(detail?.processedRows)) && Number(detail?.sizeBytes) > 0,
`processedRows=${detail?.processedRows ?? 'n/a'}, sizeBytes=${detail?.sizeBytes ?? 'n/a'}`)
const downloadResponse = await context.requestBinary('GET', `/api/platform/v1/files/export-tasks/${encodeURIComponent(taskId)}/download`)
context.addStep(module, '下载并校验客户列表 XLSX', downloadResponse, isXlsx(downloadResponse),
`bytes=${downloadResponse.bytes?.length ?? 0}, contentType=${downloadResponse.contentType || 'missing'}`)
}
@@ -0,0 +1,344 @@
import { readFile } from 'node:fs/promises'
const MODULE = 'inbound-measurement'
const SCENARIO = 'mojia-carton-measurement'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
function asId(value) { return value == null ? '' : String(value) }
function asNumber(value) { return Number(value) }
function closeTo(actual, expected, tolerance = 0.000001) {
return Number.isFinite(asNumber(actual)) && Math.abs(asNumber(actual) - expected) <= tolerance
}
function ceiling(value, scale) {
const factor = 10 ** scale
return Math.ceil((value - Number.EPSILON) * factor) / factor
}
function query(tenantId) { return `tenantId=${encodeURIComponent(tenantId)}` }
function validPdf(response) {
return response.http === 200 && response.contentType.toLowerCase().includes('pdf')
&& response.bytes?.length > 4 && new TextDecoder().decode(response.bytes.slice(0, 4)) === '%PDF'
}
function findIsolatedInbound(context) {
const roots = [context.createdResources, context.context]
const visited = new Set()
const candidates = []
function visit(value, inheritedMarker = '') {
if (!value || typeof value !== 'object' || visited.has(value)) return
visited.add(value)
const marker = String(value.marker || value.testMarker || inheritedMarker || '')
const inboundId = asId(value.inboundId || value.inbound?.id || value.inbound?.inboundId)
const tenantId = asId(value.tenantId || value.customerId || value.inbound?.tenantId)
if (marker === context.marker && inboundId && tenantId) {
candidates.push({ inboundId, tenantId, marker, source: value })
}
for (const [key, child] of Object.entries(value)) {
if (key === 'temporaryPassword' || key === 'token') continue
if (child && typeof child === 'object') visit(child, marker)
}
}
roots.forEach((root) => visit(root))
return candidates[0] || null
}
async function multipartRequest(context, path, data) {
const form = new FormData()
form.append('data', new Blob([JSON.stringify(data)], { type: 'application/json' }))
const image = await readFile(new URL('../../../apps/pda-uniapp/static/c7.png', import.meta.url))
form.append('image', new Blob([image], { type: 'image/png' }), 'carton-evidence.png')
try {
const response = await fetch(`${context.options.gateway}${path}`, {
method: 'POST',
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
body: form,
signal: AbortSignal.timeout(60_000),
})
const content = await response.text()
let body = null
try { body = content ? JSON.parse(content) : null } catch { /* Preserve only a bounded error string. */ }
return {
method: 'POST', path, http: response.status, body,
raw: body === null ? content.slice(0, 500) : null,
requestId: body?.requestId || response.headers.get('x-request-id'),
traceId: body?.traceId || response.headers.get('x-trace-id'),
}
} catch (error) {
return { method: 'POST', path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
}
}
async function requestWithHeaders(context, method, path, body, extraHeaders) {
try {
const response = await fetch(`${context.options.gateway}${path}`, {
method,
headers: {
Accept: 'application/json', Authorization: `Bearer ${context.token}`,
'Content-Type': 'application/json; charset=utf-8', ...extraHeaders,
},
body: JSON.stringify(body), signal: AbortSignal.timeout(30_000),
})
const content = await response.text()
let payload = null
try { payload = content ? JSON.parse(content) : null } catch { /* Preserve only a bounded error string. */ }
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 }
}
}
function finishScenario(context, start) {
const steps = context.steps.slice(start)
context.scenarios.push({
name: SCENARIO, module: 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,
})
}
export async function run(context) {
const start = context.steps.length
const isolated = findIsolatedInbound(context)
if (!isolated) {
context.addStep(MODULE, '取得隔离的待测量入库单', null, false,
`orders 模块未提供 marker=${context.marker} 的 inboundId/tenantId;为避免修改随机历史订单,本模块停止写入`, OPTIONS)
finishScenario(context, start)
return
}
const { inboundId, tenantId } = isolated
const detailPath = `/api/platform/inbounds/${inboundId}?${query(tenantId)}`
const inboundResponse = await context.request('GET', detailPath)
const inboundDetail = context.apiSuccess(MODULE, '查询隔离入库单详情', inboundResponse,
`inboundId=${inboundId}, tenantId=${tenantId}`, OPTIONS)
const summary = inboundDetail?.summary
const provenanceOk = asId(summary?.id) === inboundId && asId(summary?.tenantId) === tenantId
&& ['WAIT_MEASURE', 'MEASURING'].includes(summary?.processStatus)
context.assert(MODULE, '隔离入库单身份与可测量状态正确', inboundResponse, provenanceOk,
`identity=${asId(summary?.id) === inboundId && asId(summary?.tenantId) === tenantId}, status=${summary?.processStatus ?? 'missing'}`, OPTIONS)
if (!provenanceOk) {
finishScenario(context, start)
return
}
const inboundCode = String(summary.inboundCode || '')
if (!inboundCode) {
context.assert(MODULE, '箱码生成前置数据完整', inboundResponse, false,
'inboundCode=missing', OPTIONS)
finishScenario(context, start)
return
}
const labelPath = `/api/platform/inbounds/${inboundId}/carton-label-pdf?${query(tenantId)}`
const labelResponse = await context.requestBinary('POST', labelPath)
const pdfOk = validPdf(labelResponse)
context.assert(MODULE, '生成箱唛 PDF 并冻结稳定箱码', labelResponse, pdfOk,
`contentType=${labelResponse.contentType || 'missing'}, bytes=${labelResponse.bytes?.length ?? 0}`, OPTIONS)
if (!pdfOk) {
finishScenario(context, start)
return
}
const frozenInboundResponse = await context.request('GET', detailPath)
const frozenInbound = context.apiSuccess(MODULE, '首次打印后回查冻结箱数', frozenInboundResponse, '', OPTIONS)
const frozenSummary = frozenInbound?.summary
const originalPackageCount = Number(frozenSummary?.totalPackageCount)
context.assert(MODULE, '首次打印按订单货物冻结原始总箱数', frozenInboundResponse,
frozenSummary?.inboundCode === inboundCode && Number.isInteger(originalPackageCount) && originalPackageCount >= 1,
`inboundCodeStable=${frozenSummary?.inboundCode === inboundCode}, totalPackageCount=${frozenSummary?.totalPackageCount ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(originalPackageCount) || originalPackageCount < 1) {
finishScenario(context, start)
return
}
const boxCode = `${inboundCode}#${originalPackageCount}-1`
const reprintResponse = await context.requestBinary('POST', labelPath)
context.assert(MODULE, '重复整单打印复用已冻结箱码映射', reprintResponse, validPdf(reprintResponse),
`boxCode=${boxCode}, contentType=${reprintResponse.contentType || 'missing'}, bytes=${reprintResponse.bytes?.length ?? 0}`, OPTIONS)
const partialPrintPath = `${labelPath}&cartonIndex=1`
const partialPrintResponse = await context.requestBinary('POST', partialPrintPath)
context.assert(MODULE, '指定第1箱补打复用已冻结箱码映射', partialPrintResponse, validPdf(partialPrintResponse),
`boxCode=${boxCode}, cartonIndex=1, contentType=${partialPrintResponse.contentType || 'missing'}, bytes=${partialPrintResponse.bytes?.length ?? 0}`, OPTIONS)
if (!validPdf(reprintResponse) || !validPdf(partialPrintResponse)) {
finishScenario(context, start)
return
}
const scanPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/carton-scans?${query(tenantId)}`
const firstDimensions = { length: 60.1, width: 40.2, height: 30.3, weight: 12.5 }
const firstScanResponse = await multipartRequest(context, scanPath, {
boxCode, ...firstDimensions, machineNo: 'MJ-ADMIN-E2E-01', measuredAt: new Date().toISOString().slice(0, 19),
})
const firstScan = context.apiSuccess(MODULE, '模拟墨家首次扫描单箱', firstScanResponse,
`boxCode=${boxCode}, machineNo=MJ-ADMIN-E2E-01`, OPTIONS)
context.assert(MODULE, '首次扫描新增一条且默认代表1箱', firstScanResponse,
firstScan?.created === true && asId(firstScan?.boxCode) === boxCode
&& Number(firstScan?.confirmedPackageCount) === 1 && asId(firstScan?.itemId),
`created=${firstScan?.created}, confirmedPackageCount=${firstScan?.confirmedPackageCount ?? 'missing'}`, OPTIONS)
context.assert(MODULE, '重复打印及指定箱补打后原箱码仍可解析', firstScanResponse,
firstScan?.created === true && firstScan?.boxCode === boxCode,
`boxCodeStable=${firstScan?.boxCode === boxCode}, resolvedInboundId=${inboundId}`, OPTIONS)
if (!firstScan?.itemId || !firstScan?.measurementId) {
finishScenario(context, start)
return
}
const measurementPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`
const firstQueryResponse = await context.request('GET', measurementPath)
const firstQuery = context.apiSuccess(MODULE, '查询箱级测量详情', firstQueryResponse,
`measurementId=${asId(firstScan.measurementId)}`, OPTIONS)
const firstItem = firstQuery?.items?.find((item) => asId(item.id) === asId(firstScan.itemId))
context.assert(MODULE, '箱级查询回显箱码、尺寸、图片与设备信息', firstQueryResponse,
firstItem?.boxCode === boxCode && asId(firstItem?.intelligentImageFileId)
&& firstItem?.machineNo === 'MJ-ADMIN-E2E-01'
&& closeTo(firstItem?.lengthCm, firstDimensions.length)
&& closeTo(firstItem?.widthCm, firstDimensions.width)
&& closeTo(firstItem?.heightCm, firstDimensions.height),
`itemFound=${Boolean(firstItem)}, imageFileId=${asId(firstItem?.intelligentImageFileId) || 'missing'}`, OPTIONS)
const countResponse = await context.request('PUT',
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/items/${firstScan.itemId}/confirmed-package-count?${query(tenantId)}`,
{ measurementVersion: Number(firstScan.measurementVersion), confirmedPackageCount: originalPackageCount })
const countResult = context.apiSuccess(MODULE, '人工确认当前测量代表箱数', countResponse,
`confirmedPackageCount=${originalPackageCount}`, OPTIONS)
context.assert(MODULE, '人工箱数更新且测量版本递增', countResponse,
Number(countResult?.confirmedPackageCount) === originalPackageCount
&& Number(countResult?.measurementVersion) === Number(firstScan.measurementVersion) + 1,
`version=${countResult?.measurementVersion ?? 'missing'}`, OPTIONS)
const replacement = { length: 61.1, width: 41.2, height: 31.3, weight: 13.6 }
const rescanResponse = await multipartRequest(context, scanPath, {
boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-02', measuredAt: new Date().toISOString().slice(0, 19),
})
const rescan = context.apiSuccess(MODULE, '同箱号重新扫描覆盖当前记录', rescanResponse,
`boxCode=${boxCode}, machineNo=MJ-ADMIN-E2E-02`, OPTIONS)
context.assert(MODULE, '覆盖保留同一条记录和人工箱数', rescanResponse,
rescan?.created === false && asId(rescan?.itemId) === asId(firstScan.itemId)
&& Number(rescan?.confirmedPackageCount) === originalPackageCount
&& closeTo(rescan?.lengthCm, replacement.length),
`sameItem=${asId(rescan?.itemId) === asId(firstScan.itemId)}, confirmedPackageCount=${rescan?.confirmedPackageCount ?? 'missing'}`, OPTIONS)
const measuredResponse = await context.request('GET', measurementPath)
const measured = context.apiSuccess(MODULE, '回查覆盖后的尺寸与体积结果', measuredResponse, '', OPTIONS)
const measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId))
const unitVolume = ceiling(replacement.length * replacement.width * replacement.height / 1_000_000, 4)
const totalVolume = ceiling(unitVolume * originalPackageCount, 4)
const volumeWeight = ceiling(totalVolume * 167, 3)
const girth = ceiling(replacement.length + 2 * (replacement.width + replacement.height), 3)
context.assert(MODULE, '覆盖尺寸、箱数和服务端体积公式正确', measuredResponse,
measuredItem && closeTo(measuredItem.lengthCm, replacement.length)
&& closeTo(measuredItem.widthCm, replacement.width) && closeTo(measuredItem.heightCm, replacement.height)
&& Number(measuredItem.confirmedPackageCount) === originalPackageCount
&& closeTo(measuredItem.unitVolumeCbm, unitVolume) && closeTo(measuredItem.totalVolumeCbm, totalVolume)
&& closeTo(measuredItem.volumeWeightKg, volumeWeight) && closeTo(measuredItem.girthCm, girth)
&& closeTo(measured.actualVolumeCbm, totalVolume) && closeTo(measured.volumeWeightKg, volumeWeight),
`unitVolume=${unitVolume}, totalVolume=${totalVolume}, volumeWeight=${volumeWeight}, girth=${girth}`, OPTIONS)
let inboundDetailId = measuredItem?.inboundDetailId
if (!inboundDetailId) {
const orderId = asId(frozenSummary?.orderId || context.createdResources?.orders?.orderId)
const orderDetailResponse = await context.request('GET',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}?${query(tenantId)}`)
const orderDetail = context.apiSuccess(MODULE, '测量详情缺少货物ID时回查隔离订单', orderDetailResponse,
`orderId=${orderId}`, OPTIONS)
const cargoes = Array.isArray(orderDetail?.cargoes) ? [...orderDetail.cargoes] : []
cargoes.sort((left, right) => Number(left?.itemNo ?? Number.MAX_SAFE_INTEGER)
- Number(right?.itemNo ?? Number.MAX_SAFE_INTEGER) || asId(left?.id).localeCompare(asId(right?.id)))
const firstCargo = cargoes[0]
const cargoBelongsToRun = asId(orderDetail?.order?.id) === orderId
&& String(firstCargo?.cargoRemark || '').includes(context.marker)
inboundDetailId = cargoBelongsToRun ? firstCargo?.id : null
context.assert(MODULE, '草稿绑定本轮箱码对应的隔离货物', orderDetailResponse,
cargoBelongsToRun && asId(inboundDetailId),
`cargoCount=${cargoes.length}, markerMatched=${cargoBelongsToRun}, inboundDetailId=${asId(inboundDetailId) || 'missing'}`, OPTIONS)
}
if (!inboundDetailId || !measuredItem?.intelligentImageFileId) {
context.assert(MODULE, '草稿保存前置字段完整', measuredResponse, false,
`inboundDetailId=${asId(inboundDetailId) || 'missing'}, imageFileId=${asId(measuredItem?.intelligentImageFileId) || 'missing'}`, OPTIONS)
finishScenario(context, start)
return
}
const draftPayload = {
measurementId: measured.measurementId, version: Number(measured.version),
originalDocumentNo: context.marker, originalGrossWeightKg: replacement.weight * originalPackageCount,
originalVolumeCbm: totalVolume, actualGoodsName: `墨家测量验收-${context.marker.slice(-12)}`,
items: [{
inboundDetailId, itemNo: measuredItem.itemNo,
boxSpecification: measuredItem.boxSpecification, packageCount: originalPackageCount,
lengthCm: replacement.length, widthCm: replacement.width, heightCm: replacement.height,
unitWeightKg: replacement.weight, intelligentImageFileId: measuredItem.intelligentImageFileId,
overLimitWarning: measuredItem.overLimitWarning ?? null,
outerLengthCm: measuredItem.outerLengthCm ?? null, outerWidthCm: measuredItem.outerWidthCm ?? null,
outerHeightCm: measuredItem.outerHeightCm ?? null, remark: context.marker,
}],
}
const draftResponse = await context.request('PUT',
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/draft?${query(tenantId)}`, draftPayload)
const draft = context.apiSuccess(MODULE, '保存同一测量批次草稿', draftResponse,
`measurementId=${asId(measured.measurementId)}, items=1`, OPTIONS)
context.assert(MODULE, '草稿沿用批次并递增版本', draftResponse,
asId(draft?.measurementId) === asId(measured.measurementId)
&& Number(draft?.version) === Number(measured.version) + 1 && draft?.status === 'DRAFT',
`status=${draft?.status ?? 'missing'}, version=${draft?.version ?? 'missing'}`, OPTIONS)
if (!draft?.measurementId) {
finishScenario(context, start)
return
}
const submitResponse = await context.request('POST',
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/submit?${query(tenantId)}`,
{ measurementId: draft.measurementId, version: Number(draft.version) })
const submitted = context.apiSuccess(MODULE, '提交测量审核', submitResponse,
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
context.assert(MODULE, '提交后进入待测量审核状态', submitResponse,
submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING',
`processStatus=${submitted?.processStatus ?? 'missing'}, reviewStatus=${submitted?.measureReviewStatus ?? 'missing'}`, OPTIONS)
const rejectedScanResponse = await multipartRequest(context, scanPath, {
boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-03', measuredAt: new Date().toISOString().slice(0, 19),
})
const rejected = rejectedScanResponse.http === 200 && String(rejectedScanResponse.body?.code) === 'A0409'
&& String(rejectedScanResponse.body?.message || '').includes('测量已提交')
context.assert(MODULE, '测量提交后拒绝设备覆盖并返回中文提示', rejectedScanResponse, rejected,
`HTTP=${rejectedScanResponse.http}, code=${rejectedScanResponse.body?.code ?? 'missing'}, chineseMessage=${String(rejectedScanResponse.body?.message || '').includes('测量已提交')}`, OPTIONS)
const pendingInboundResponse = await context.request('GET', detailPath)
const pendingInbound = context.apiSuccess(MODULE, '审核前读取最新入库单版本', pendingInboundResponse, '', OPTIONS)
const pendingSummary = pendingInbound?.summary
context.assert(MODULE, '审核前状态和版本有效', pendingInboundResponse,
pendingSummary?.processStatus === 'MEASURE_PENDING' && pendingSummary?.measureReviewStatus === 'PENDING'
&& Number.isInteger(Number(pendingSummary?.version)),
`status=${pendingSummary?.processStatus ?? 'missing'}, version=${pendingSummary?.version ?? 'missing'}`, OPTIONS)
const approvalPath = `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${query(tenantId)}`
const approvalResponse = await requestWithHeaders(context, 'POST', approvalPath, {
measurementId: draft.measurementId, expectedVersion: Number(pendingSummary?.version), reason: null,
}, { 'Idempotency-Key': `${context.marker}-mojia-approve`.slice(0, 128) })
context.apiSuccess(MODULE, 'Admin 审核通过墨家测量', approvalResponse,
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
const finalResponse = await context.request('GET', measurementPath)
const finalMeasurement = context.apiSuccess(MODULE, '查询审核后的最终测量状态', finalResponse, '', OPTIONS)
context.assert(MODULE, '审核后入库与测量状态闭环且结果未变化', finalResponse,
finalMeasurement?.processStatus === 'INBOUNDED' && finalMeasurement?.measureReviewStatus === 'APPROVED'
&& finalMeasurement?.measurementStatus === 'APPROVED'
&& Number(finalMeasurement?.actualPackageCount) === originalPackageCount
&& closeTo(finalMeasurement?.actualVolumeCbm, totalVolume)
&& closeTo(finalMeasurement?.volumeWeightKg, volumeWeight),
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}`, OPTIONS)
context.createdResources.inboundMeasurement = {
marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId),
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
}
finishScenario(context, start)
}
@@ -0,0 +1,452 @@
import { readFile } from 'node:fs/promises'
import { basename } from 'node:path'
import process from 'node:process'
const MODULE = 'inbound-weight-ocr'
const SCENARIO = 'weight-image-recognition-and-confirmation'
const DEFAULT_IMAGE = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
const EDITABLE_STATUSES = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
const OCR_STATUSES = new Set(['RECOGNIZED', 'FAILED'])
const NEW_CONTRACT_OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const SCENARIO_OPTIONS = { scenario: SCENARIO }
function id(value) {
return value == null ? '' : String(value)
}
function apiOk(response) {
return response?.http === 200 && String(response?.body?.code) === '200'
}
function closeNumber(actual, expected, tolerance = 0.0005) {
return Number.isFinite(Number(actual)) && Math.abs(Number(actual) - Number(expected)) < tolerance
}
function expectedConflict(response) {
return response?.http === 200 && ['A0409', '409'].includes(String(response?.body?.code))
}
function expectedRejection(response) {
return response?.http === 200 && response?.body && String(response.body.code) !== '200'
}
function markerVisible(detail, marker) {
const values = [
detail?.publicRemark,
detail?.summary?.inboundCode,
detail?.summary?.orderNo,
detail?.summary?.customerName,
detail?.summary?.companyName,
...(Array.isArray(detail?.internalNotes) ? detail.internalNotes.map((note) => note?.content) : []),
]
return values.some((value) => typeof value === 'string' && value.includes(marker))
}
function candidateValues(context) {
const shared = context.context || {}
return [
shared.inboundWeightOcr,
shared.ocrInbound,
shared.testInbound,
shared.createdInbound,
shared.inboundId && shared.tenantId
? { inboundId: shared.inboundId, tenantId: shared.tenantId, marker: shared.inboundMarker }
: null,
].filter(Boolean)
}
async function resolveMarkedInbound(context) {
for (const candidate of candidateValues(context)) {
const tenantId = id(candidate.tenantId || candidate.customerId)
const inboundId = id(candidate.inboundId || candidate.id)
if (!tenantId || !inboundId) continue
const response = await context.request(
'GET', `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`,
)
const detail = apiOk(response) ? response.body.data : null
const marked = markerVisible(detail, context.marker)
const editable = EDITABLE_STATUSES.has(detail?.summary?.processStatus)
if (detail && marked && editable) return { tenantId, inboundId, detail, response }
}
const filters = [
`inboundCode=${encodeURIComponent(context.marker)}`,
`orderNo=${encodeURIComponent(context.marker)}`,
]
for (const filter of filters) {
const pageResponse = await context.request('GET', `/api/platform/inbounds?page=1&size=20&${filter}`)
if (!apiOk(pageResponse)) continue
for (const row of pageResponse.body.data?.records || []) {
const tenantId = id(row.tenantId)
const inboundId = id(row.id)
if (!tenantId || !inboundId || !EDITABLE_STATUSES.has(row.processStatus)) continue
const detailResponse = await context.request(
'GET', `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`,
)
const detail = apiOk(detailResponse) ? detailResponse.body.data : null
if (detail && markerVisible(detail, context.marker)) {
return { tenantId, inboundId, detail, response: detailResponse }
}
}
}
return null
}
async function multipart(context, path, file, token = context.token) {
const form = new FormData()
form.append('file', new Blob([file.bytes], { type: file.contentType }), file.name)
const headers = { Accept: 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
try {
const response = await fetch(`${context.options.gateway}${path}`, {
method: 'POST', headers, body: form, signal: AbortSignal.timeout(120_000),
})
const content = await response.text()
let payload = null
try { payload = content ? JSON.parse(content) : null } catch { /* Keep bounded diagnostic text only. */ }
return {
method: 'POST', 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: 'POST', path, http: 0, body: null,
raw: error instanceof Error ? error.message : String(error),
requestId: null, traceId: null,
}
}
}
function jpegDimensions(bytes) {
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null
const standalone = new Set([0x01, 0xd8, 0xd9, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7])
const startsOfFrame = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf])
let offset = 2
while (offset + 3 < bytes.length) {
while (offset < bytes.length && bytes[offset] !== 0xff) offset += 1
while (offset < bytes.length && bytes[offset] === 0xff) offset += 1
if (offset >= bytes.length) break
const marker = bytes[offset]
offset += 1
if (standalone.has(marker)) continue
if (offset + 1 >= bytes.length) break
const length = bytes[offset] * 256 + bytes[offset + 1]
if (length < 2 || offset + length > bytes.length) break
if (startsOfFrame.has(marker) && length >= 7) {
return {
height: bytes[offset + 3] * 256 + bytes[offset + 4],
width: bytes[offset + 5] * 256 + bytes[offset + 6],
}
}
offset += length
}
return null
}
function workspacePath(tenantId, inboundId) {
return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/measurement?tenantId=${encodeURIComponent(tenantId)}`
}
function resultPath(inboundId, resultId) {
return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/weight-results/${encodeURIComponent(resultId)}`
}
function confirmPath(tenantId, inboundId, resultId) {
return `${resultPath(inboundId, resultId)}/confirm?tenantId=${encodeURIComponent(tenantId)}`
}
function deletePath(tenantId, inboundId, resultId, version) {
return `${resultPath(inboundId, resultId)}?tenantId=${encodeURIComponent(tenantId)}&version=${version}`
}
async function createUploadedResult(context, target, image, overrides = {}) {
const imagePath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}`
const uploadResponse = await multipart(context, imagePath, image)
const upload = context.apiSuccess(MODULE, '上传称重图片并执行 OCR', uploadResponse,
`file=${image.name}, size=${image.bytes.length}`, SCENARIO_OPTIONS)
const fileId = id(upload?.fileId)
context.assert(MODULE, '上传响应保持文件 ID 字符串契约', uploadResponse,
Boolean(fileId) && typeof upload?.fileId === 'string',
`fileIdType=${typeof upload?.fileId}, status=${upload?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS)
context.assert(MODULE, 'OCR 上传响应字段完整且置信度受限', uploadResponse,
upload && OCR_STATUSES.has(upload.recognitionStatus)
&& Object.hasOwn(upload, 'weightValue') && Object.hasOwn(upload, 'weightUnit')
&& Object.hasOwn(upload, 'confidence')
&& (upload.confidence == null || (Number(upload.confidence) >= 0 && Number(upload.confidence) <= 1)),
`status=${upload?.recognitionStatus ?? 'missing'}, hasWeight=${upload?.weightValue != null}, confidence=${upload?.confidence ?? 'null'}`,
SCENARIO_OPTIONS)
if (!fileId) throw new Error('称重图片上传未返回 fileId,无法登记和清理测试资源')
let recognized = upload
if (overrides.retry) {
const retryPath = `${imagePath}/${encodeURIComponent(fileId)}/recognize`
const retryResponse = await context.request('POST', retryPath)
const retry = context.apiSuccess(MODULE, '重试当前称重图片 OCR', retryResponse,
`fileId=${fileId}`, SCENARIO_OPTIONS)
context.assert(MODULE, 'OCR 重试保持文件身份并返回识别契约', retryResponse,
id(retry?.fileId) === fileId && OCR_STATUSES.has(retry?.recognitionStatus)
&& Object.hasOwn(retry || {}, 'weightValue') && Object.hasOwn(retry || {}, 'confidence'),
`sameFile=${id(retry?.fileId) === fileId}, status=${retry?.recognitionStatus ?? 'missing'}`,
SCENARIO_OPTIONS)
if (retry) recognized = retry
}
const payload = {
fileId,
ocrProvider: `ADMIN_ACCEPTANCE_${context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-24)}`,
ocrRawText: overrides.manual ? null : (recognized.ocrRawText || null),
ocrWeightValue: overrides.manual ? null : recognized.weightValue,
ocrWeightUnit: overrides.manual ? null : recognized.weightUnit,
confidence: overrides.manual ? null : recognized.confidence,
}
const createPath = `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(target.inboundId)}/weight-results?tenantId=${encodeURIComponent(target.tenantId)}`
let createResponse = await context.request('POST', createPath, payload)
let result = context.apiSuccess(MODULE, overrides.manual ? '登记人工录入重量结果' : '登记 OCR 原值',
createResponse, `fileId=${fileId}`, SCENARIO_OPTIONS)
if (!result && !overrides.manual) {
createResponse = await context.request('POST', createPath, {
...payload, ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null,
})
result = context.apiSuccess(MODULE, 'OCR 原值登记失败后建立可清理人工结果', createResponse,
`fileId=${fileId}`, NEW_CONTRACT_OPTIONS)
}
const resultId = id(result?.id)
context.assert(MODULE, '重量结果 ID、文件 ID 和初始版本契约正确', createResponse,
Boolean(resultId) && typeof result?.id === 'string' && typeof result?.fileId === 'string'
&& id(result?.fileId) === fileId && Number(result?.version) === 0,
`resultIdType=${typeof result?.id}, fileIdType=${typeof result?.fileId}, version=${result?.version ?? 'missing'}`,
SCENARIO_OPTIONS)
if (!resultId) throw new Error(`文件 ${fileId} 已上传但重量结果登记失败,需要人工清理该测试文件`)
return { upload: recognized, fileId, result, resultId, version: Number(result.version), deleted: false }
}
async function verifyImages(context, target, item, image) {
const base = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}/${encodeURIComponent(item.fileId)}`
const original = await context.requestBinary('GET', base)
context.assert(MODULE, '鉴权读取原始称重图片', original,
original.http === 200 && original.bytes.length === image.bytes.length
&& /^image\/(jpeg|png|bmp)/i.test(original.contentType),
`contentType=${original.contentType || 'missing'}, bytes=${original.bytes.length}`, SCENARIO_OPTIONS)
const thumbnail = await context.requestBinary('GET', `${base}/thumbnail`)
const dimensions = jpegDimensions(thumbnail.bytes)
context.assert(MODULE, '缩略图返回受限尺寸 JPEG', thumbnail,
thumbnail.http === 200 && /^image\/jpeg/i.test(thumbnail.contentType)
&& thumbnail.bytes.length > 0 && dimensions && dimensions.width <= 480 && dimensions.height <= 320,
`contentType=${thumbnail.contentType || 'missing'}, bytes=${thumbnail.bytes.length}, dimensions=${dimensions ? `${dimensions.width}x${dimensions.height}` : 'unreadable'}`,
NEW_CONTRACT_OPTIONS)
}
async function loadWorkspace(context, target, name) {
const response = await context.request('GET', workspacePath(target.tenantId, target.inboundId))
const workspace = context.apiSuccess(MODULE, name, response, `inboundId=${target.inboundId}`, SCENARIO_OPTIONS)
return { response, workspace }
}
async function rejectWithoutMutation(context, target, item, name, payload) {
const path = confirmPath(target.tenantId, target.inboundId, item.resultId)
const response = await context.request('POST', path, payload)
context.assert(MODULE, name, response, expectedRejection(response),
`HTTP=${response.http}, code=${response.body?.code ?? 'missing'}`, SCENARIO_OPTIONS)
const check = await loadWorkspace(context, target, `${name}后回查工作区`)
const persisted = check.workspace?.weightResults?.find((row) => id(row.id) === item.resultId)
context.assert(MODULE, `${name}未改变重量结果`, check.response,
persisted && Number(persisted.version) === item.version && persisted.recognitionStatus !== 'CONFIRMED',
`version=${persisted?.version ?? 'missing'}, status=${persisted?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS)
}
async function deleteResult(context, target, item, name, options = NEW_CONTRACT_OPTIONS) {
const path = deletePath(target.tenantId, target.inboundId, item.resultId, item.version)
const response = await context.request('DELETE', path)
const passed = apiOk(response)
context.addStep(MODULE, name, response, passed,
`resultId=${item.resultId}, version=${item.version}`, options)
if (passed) item.deleted = true
return { response, passed }
}
async function verifyDeleted(context, target, item, baselineWeight) {
const check = await loadWorkspace(context, target, '删除后回查重量工作区')
const missing = !check.workspace?.weightResults?.some((row) => id(row.id) === item.resultId)
context.assert(MODULE, '删除后结果消失且总毛重恢复基线', check.response,
missing && (baselineWeight == null
? check.workspace?.actualGrossWeightKg == null
: closeNumber(check.workspace?.actualGrossWeightKg, baselineWeight)),
`resultMissing=${missing}, baseline=${baselineWeight ?? 'null'}, actual=${check.workspace?.actualGrossWeightKg ?? 'null'}`,
NEW_CONTRACT_OPTIONS)
const imagePath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}/${encodeURIComponent(item.fileId)}`
const image = await context.requestBinary('GET', imagePath)
context.assert(MODULE, '删除结果后原始图片不可再读取', image,
image.http === 200 && image.body && String(image.body.code) !== '200',
`HTTP=${image.http}, code=${image.body?.code ?? 'missing'}`, NEW_CONTRACT_OPTIONS)
}
export async function run(context) {
const scenarioStart = context.steps.length
const created = []
const target = await resolveMarkedInbound(context)
context.assert(MODULE, '仅选择带本轮测试标记的可编辑入库单', target?.response || null,
Boolean(target), target
? `tenantId=${target.tenantId}, inboundId=${target.inboundId}, status=${target.detail.summary.processStatus}`
: `marker=${context.marker}, 未找到本轮创建且状态为 ${[...EDITABLE_STATUSES].join('/')} 的入库单`,
SCENARIO_OPTIONS)
if (!target) {
context.scenarios.push({
name: SCENARIO, module: MODULE, success: false, deploymentBlocked: false,
passed: 0, failed: 1,
})
return
}
context.context.inboundWeightOcr = {
tenantId: target.tenantId, inboundId: target.inboundId, marker: context.marker,
}
context.createdResources.inboundWeightOcr = {
tenantId: target.tenantId, inboundId: target.inboundId, resultIds: [], cleaned: false,
}
const imagePath = process.env.ADMIN_TEST_OCR_IMAGE || DEFAULT_IMAGE
const imageBytes = await readFile(imagePath)
const image = { bytes: imageBytes, name: basename(imagePath), contentType: 'image/jpeg' }
const initial = await loadWorkspace(context, target, '读取 OCR 测试前重量工作区')
if (!initial.workspace) throw new Error('无法读取测试入库单重量工作区')
const baselineWeight = initial.workspace.actualGrossWeightKg
const baselineIds = new Set((initial.workspace.weightResults || []).map((row) => id(row.id)))
context.assert(MODULE, '测试入库单身份与基线可追踪', initial.response,
id(initial.workspace.inboundId) === target.inboundId
&& initial.workspace.processStatus === target.detail.summary.processStatus,
`existingResults=${baselineIds.size}, baselineWeight=${baselineWeight ?? 'null'}`, SCENARIO_OPTIONS)
const uploadPath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}`
const noAuth = await multipart(context, uploadPath, image, '')
context.assert(MODULE, '未认证请求不能上传称重图片', noAuth,
noAuth.http === 401 || noAuth.http === 403,
`HTTP=${noAuth.http}, code=${noAuth.body?.code ?? 'none'}`, SCENARIO_OPTIONS)
const wrongType = await multipart(context, uploadPath, {
bytes: new TextEncoder().encode(context.marker), name: `${context.marker}.txt`, contentType: 'text/plain',
})
context.assert(MODULE, '非图片类型被拒绝且不模拟成功', wrongType,
expectedRejection(wrongType), `HTTP=${wrongType.http}, code=${wrongType.body?.code ?? 'missing'}`, SCENARIO_OPTIONS)
const oversized = await multipart(context, uploadPath, {
bytes: new Uint8Array(5 * 1024 * 1024 + 1), name: `${context.marker}.jpg`, contentType: 'image/jpeg',
})
context.assert(MODULE, '超过5MB图片被拒绝', oversized,
expectedRejection(oversized), `HTTP=${oversized.http}, code=${oversized.body?.code ?? 'missing'}`, SCENARIO_OPTIONS)
try {
const original = await createUploadedResult(context, target, image, { retry: true })
created.push(original)
context.createdResources.inboundWeightOcr.resultIds.push(original.resultId)
await verifyImages(context, target, original, image)
const registered = await loadWorkspace(context, target, '登记后回查 OCR 重量结果')
const registeredRow = registered.workspace?.weightResults?.find((row) => id(row.id) === original.resultId)
context.assert(MODULE, '工作区保留本轮 OCR 原值且未覆盖历史结果', registered.response,
registeredRow && !baselineIds.has(original.resultId)
&& closeNumber(registeredRow.ocrWeightValue, original.result.ocrWeightValue)
&& registeredRow.ocrWeightUnit === original.result.ocrWeightUnit,
`resultFound=${Boolean(registeredRow)}, baselineCount=${baselineIds.size}, currentCount=${registered.workspace?.weightResults?.length ?? 'n/a'}`,
SCENARIO_OPTIONS)
const staleConfirm = await context.request('POST',
confirmPath(target.tenantId, target.inboundId, original.resultId),
{ version: original.version + 1 })
context.assert(MODULE, '旧版本确认返回并发冲突', staleConfirm, expectedConflict(staleConfirm),
`HTTP=${staleConfirm.http}, code=${staleConfirm.body?.code ?? 'missing'}`, NEW_CONTRACT_OPTIONS)
const recognizedValue = Number(original.result.ocrWeightValue)
const recognizedUnit = original.result.ocrWeightUnit
const canConfirmOriginal = Number.isFinite(recognizedValue) && recognizedValue > 0
&& ['KG', 'KGS', 'LB', 'LBS'].includes(String(recognizedUnit || '').trim().toUpperCase())
const confirmPayload = canConfirmOriginal
? { version: original.version }
: {
version: original.version, adjustedWeightValue: 1.234, adjustedWeightUnit: 'KG',
adjustmentReason: `${context.marker} OCR未返回有效重量,人工验收`,
}
const confirmResponse = await context.request('POST',
confirmPath(target.tenantId, target.inboundId, original.resultId), confirmPayload)
const confirmed = context.apiSuccess(MODULE, '确认 OCR 重量并计入实际总毛重', confirmResponse,
`usedOriginal=${canConfirmOriginal}`, NEW_CONTRACT_OPTIONS)
context.assert(MODULE, '确认结果状态、版本和最终KG正确', confirmResponse,
confirmed?.recognitionStatus === 'CONFIRMED' && Number(confirmed?.version) === original.version + 1
&& Number(confirmed?.finalWeightKg) > 0,
`status=${confirmed?.recognitionStatus ?? 'missing'}, version=${confirmed?.version ?? 'missing'}, finalKg=${confirmed?.finalWeightKg ?? 'missing'}`,
NEW_CONTRACT_OPTIONS)
original.version = Number(confirmed?.version ?? original.version)
const afterConfirm = await loadWorkspace(context, target, '确认后回查实际总毛重')
const expectedTotal = Number(baselineWeight || 0) + Number(confirmed?.finalWeightKg || 0)
context.assert(MODULE, '确认后实际总毛重按服务端结果重算', afterConfirm.response,
closeNumber(afterConfirm.workspace?.actualGrossWeightKg, expectedTotal),
`baseline=${baselineWeight ?? 'null'}, added=${confirmed?.finalWeightKg ?? 'missing'}, actual=${afterConfirm.workspace?.actualGrossWeightKg ?? 'missing'}`,
NEW_CONTRACT_OPTIONS)
const staleDelete = await context.request('DELETE',
deletePath(target.tenantId, target.inboundId, original.resultId, Math.max(0, original.version - 1)))
context.assert(MODULE, '旧版本删除返回并发冲突', staleDelete, expectedConflict(staleDelete),
`HTTP=${staleDelete.http}, code=${staleDelete.body?.code ?? 'missing'}`, NEW_CONTRACT_OPTIONS)
const deletedOriginal = await deleteResult(context, target, original, '删除本轮已确认图片及重量结果')
if (deletedOriginal.passed) await verifyDeleted(context, target, original, baselineWeight)
const manual = await createUploadedResult(context, target, image, { manual: true })
created.push(manual)
context.createdResources.inboundWeightOcr.resultIds.push(manual.resultId)
await rejectWithoutMutation(context, target, manual, '零重量确认被拒绝', {
version: manual.version, adjustedWeightValue: 0, adjustedWeightUnit: 'KG', adjustmentReason: context.marker,
})
await rejectWithoutMutation(context, target, manual, '负重量确认被拒绝', {
version: manual.version, adjustedWeightValue: -1, adjustedWeightUnit: 'KG', adjustmentReason: context.marker,
})
await rejectWithoutMutation(context, target, manual, 'LB 0.001换算为零后被拒绝', {
version: manual.version, adjustedWeightValue: 0.001, adjustedWeightUnit: 'LB', adjustmentReason: context.marker,
})
await rejectWithoutMutation(context, target, manual, '超出15位整数重量被拒绝', {
version: manual.version, adjustedWeightValue: 1_000_000_000_000_000,
adjustedWeightUnit: 'KG', adjustmentReason: context.marker,
})
const adjustedValue = 10.125
const adjustedConfirmResponse = await context.request('POST',
confirmPath(target.tenantId, target.inboundId, manual.resultId), {
version: manual.version, adjustedWeightValue: adjustedValue, adjustedWeightUnit: 'LB',
adjustmentReason: `${context.marker} 人工确认 LB`,
})
const adjusted = context.apiSuccess(MODULE, '人工改重与确认原子完成', adjustedConfirmResponse,
`adjusted=${adjustedValue} LB`, NEW_CONTRACT_OPTIONS)
const expectedKg = Math.round(adjustedValue * 0.45359237 * 1000) / 1000
context.assert(MODULE, 'LB 人工改重保留输入并正确换算最终KG', adjustedConfirmResponse,
adjusted?.recognitionStatus === 'CONFIRMED' && Number(adjusted?.version) === manual.version + 2
&& closeNumber(adjusted?.adjustedWeightValue, adjustedValue)
&& adjusted?.adjustedWeightUnit === 'LB' && closeNumber(adjusted?.finalWeightKg, expectedKg),
`adjusted=${adjusted?.adjustedWeightValue ?? 'missing'} ${adjusted?.adjustedWeightUnit ?? ''}, finalKg=${adjusted?.finalWeightKg ?? 'missing'}, expectedKg=${expectedKg}`,
NEW_CONTRACT_OPTIONS)
manual.version = Number(adjusted?.version ?? manual.version)
const deletedManual = await deleteResult(context, target, manual, '删除本轮人工改重图片及结果')
if (deletedManual.passed) await verifyDeleted(context, target, manual, baselineWeight)
} finally {
for (const item of created.filter((value) => !value.deleted)) {
await deleteResult(context, target, item, '异常收尾清理本轮重量结果', NEW_CONTRACT_OPTIONS)
}
const cleanup = await loadWorkspace(context, target, '最终清理后回查重量工作区')
const createdIds = new Set(created.map((item) => item.resultId))
const leftovers = (cleanup.workspace?.weightResults || []).filter((row) => createdIds.has(id(row.id)))
const restored = leftovers.length === 0 && (baselineWeight == null
? cleanup.workspace?.actualGrossWeightKg == null
: closeNumber(cleanup.workspace?.actualGrossWeightKg, baselineWeight))
context.assert(MODULE, '仅清理本轮结果且订单总毛重恢复运行前基线', cleanup.response,
restored, `leftovers=${leftovers.length}, baseline=${baselineWeight ?? 'null'}, actual=${cleanup.workspace?.actualGrossWeightKg ?? 'null'}`,
NEW_CONTRACT_OPTIONS)
context.createdResources.inboundWeightOcr.cleaned = restored
}
const steps = context.steps.slice(scenarioStart)
context.scenarios.push({
name: SCENARIO, module: 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,
})
}
@@ -0,0 +1,157 @@
function id(value) { return value == null ? '' : String(value) }
function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite(Number(value?.total)) }
function xlsxOk(response) {
return response.http === 200 && response.bytes?.length > 4
&& response.bytes[0] === 0x50 && response.bytes[1] === 0x4b
}
export async function run(context) {
const module = 'master-data'
const suffix = context.marker.replace(/\D/g, '').slice(-12)
const warehouseListResponse = await context.request('GET', '/api/platform/v1/warehouse-admin?page=1&size=20')
const warehousePage = context.apiSuccess(module, '查询平台仓库列表', warehouseListResponse)
context.assert(module, '仓库列表分页结构正确', warehouseListResponse, pageOk(warehousePage), `total=${warehousePage?.total ?? 'n/a'}`)
const warehousePayload = {
tenantId: 0, warehouseCode: `AW${suffix}`, warehouseName: `Admin验收仓-${suffix}`,
warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai',
contactName: '仓库验收员', contactPhone: '13800000001', contactEmail: `warehouse-${suffix}@example.com`,
status: 'ACTIVE', remark: context.marker,
address: {
addressType: 'WAREHOUSE', countryCode: 'CN', stateProvince: '广东省', city: '深圳市',
district: '南山区', postalCode: '518000', addressLine1: 'Admin API 验收仓地址',
addressLine2: null, latitude: null, longitude: null,
},
capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: '收货', enabled: true, cutoffTime: '16:00:00', description: context.marker }],
}
const createWarehouseResponse = await context.request('POST', '/api/platform/v1/warehouse-admin', warehousePayload)
const warehouseId = id(context.apiSuccess(module, '创建平台公共仓库', createWarehouseResponse))
context.assert(module, '仓库创建返回字符串兼容 ID', createWarehouseResponse, Boolean(warehouseId), `warehouseId=${warehouseId || 'missing'}`)
if (!warehouseId) throw new Error('平台仓库创建失败,库位和托盘场景无法继续')
context.context.warehouseId = warehouseId
context.createdResources.warehouse = { id: warehouseId, code: warehousePayload.warehouseCode, finalStatus: 'INACTIVE' }
const warehousePath = `/api/platform/v1/warehouse-admin/${warehouseId}?tenantId=0`
const warehouseDetailResponse = await context.request('GET', warehousePath)
const warehouseDetail = context.apiSuccess(module, '查询平台仓库详情', warehouseDetailResponse)
context.assert(module, '仓库详情、地址和能力正确', warehouseDetailResponse,
id(warehouseDetail?.warehouse?.id) === warehouseId && warehouseDetail?.addresses?.length === 1 && warehouseDetail?.capabilities?.length === 1,
`addresses=${warehouseDetail?.addresses?.length ?? 'n/a'}, capabilities=${warehouseDetail?.capabilities?.length ?? 'n/a'}`)
const editedWarehouse = { ...warehousePayload, warehouseName: `${warehousePayload.warehouseName}-已编辑`, remark: `${context.marker}-EDITED` }
const updateWarehouseResponse = await context.request('PUT', warehousePath, editedWarehouse)
context.apiSuccess(module, '编辑平台仓库', updateWarehouseResponse)
const warehouseAfterEditResponse = await context.request('GET', warehousePath)
const warehouseAfterEdit = context.apiSuccess(module, '回查编辑后的平台仓库', warehouseAfterEditResponse)
context.assert(module, '仓库编辑已持久化', warehouseAfterEditResponse,
warehouseAfterEdit?.warehouse?.warehouseName === editedWarehouse.warehouseName && warehouseAfterEdit?.warehouse?.remark === editedWarehouse.remark,
`warehouseId=${warehouseId}`)
const locationPayload = {
warehouseId, zoneCode: `Z${suffix.slice(-5)}`, zoneName: 'Admin验收区',
locationCode: `L${suffix}`, locationName: 'Admin验收库位', locationType: 'SHELF',
maxWeightKg: 1000, status: 'AVAILABLE', remark: context.marker,
}
const createLocationResponse = await context.request('POST', '/api/platform/v1/locations?tenantId=0', locationPayload)
const locationId = id(context.apiSuccess(module, '创建仓库库位', createLocationResponse))
if (!locationId) throw new Error('库位创建失败')
context.createdResources.location = { id: locationId, warehouseId, deleted: false }
const locationPath = `/api/platform/v1/locations/${locationId}?tenantId=0&warehouseId=${warehouseId}`
const locationDetailResponse = await context.request('GET', locationPath)
const locationDetail = context.apiSuccess(module, '查询库位详情', locationDetailResponse)
context.assert(module, '库位详情关联正确仓库', locationDetailResponse,
id(locationDetail?.location?.id) === locationId && id(locationDetail?.location?.warehouseId) === warehouseId,
`locationId=${locationId}`)
const locationVersion = Number(locationDetail?.location?.version)
const updateLocationResponse = await context.request('PUT', locationPath, { ...locationPayload, locationName: 'Admin验收库位-已编辑', maxWeightKg: 1200, version: locationVersion })
context.apiSuccess(module, '编辑库位', updateLocationResponse)
const locationAfterEditResponse = await context.request('GET', locationPath)
const locationAfterEdit = context.apiSuccess(module, '回查编辑后的库位', locationAfterEditResponse)
context.assert(module, '库位编辑与版本递增正确', locationAfterEditResponse,
locationAfterEdit?.location?.locationName === 'Admin验收库位-已编辑' && Number(locationAfterEdit?.location?.version) === locationVersion + 1,
`version=${locationAfterEdit?.location?.version ?? 'missing'}`)
const disabledLocationVersion = Number(locationAfterEdit?.location?.version)
const disableLocationResponse = await context.request('PATCH', `/api/platform/v1/locations/${locationId}/status?tenantId=0&warehouseId=${warehouseId}`, { status: 'DISABLED', version: disabledLocationVersion })
context.apiSuccess(module, '停用库位', disableLocationResponse)
const deleteLocationResponse = await context.request('DELETE', `/api/platform/v1/locations/${locationId}?tenantId=0&warehouseId=${warehouseId}&version=${disabledLocationVersion + 1}`)
const deletedLocation = context.apiSuccess(module, '删除未占用测试库位', deleteLocationResponse)
if (deletedLocation === null && String(deleteLocationResponse.body?.code) === '200') context.createdResources.location.deleted = true
const palletPayload = { warehouseId, palletType: 'STANDARD', capacityCbm: 1.5, weightKg: 25, color: 'BLUE', remark: context.marker }
const createPalletResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', palletPayload)
const palletId = id(context.apiSuccess(module, '创建仓库托盘', createPalletResponse))
if (!palletId) throw new Error('托盘创建失败')
context.createdResources.pallet = { id: palletId, warehouseId, deleted: false }
const palletPath = `/api/platform/v1/pallets/${palletId}?tenantId=0&warehouseId=${warehouseId}`
const palletDetailResponse = await context.request('GET', palletPath)
const pallet = context.apiSuccess(module, '查询托盘详情', palletDetailResponse)
context.assert(module, '托盘详情关联正确仓库', palletDetailResponse,
id(pallet?.id) === palletId && id(pallet?.warehouseId) === warehouseId, `palletId=${palletId}`)
const updatePalletResponse = await context.request('PUT', palletPath, { ...palletPayload, capacityCbm: 1.75, weightKg: 26, remark: `${context.marker}-EDITED` })
context.apiSuccess(module, '编辑托盘', updatePalletResponse)
const palletAfterEditResponse = await context.request('GET', palletPath)
const palletAfterEdit = context.apiSuccess(module, '回查编辑后的托盘', palletAfterEditResponse)
context.assert(module, '托盘编辑已持久化', palletAfterEditResponse,
Number(palletAfterEdit?.capacityCbm) === 1.75 && palletAfterEdit?.remark === `${context.marker}-EDITED`, `palletId=${palletId}`)
const deletePalletResponse = await context.request('DELETE', palletPath)
context.apiSuccess(module, '删除未使用测试托盘', deletePalletResponse)
if (String(deletePalletResponse.body?.code) === '200') context.createdResources.pallet.deleted = true
const expressPayload = {
companyCode: `EX${suffix}`, companyName: `Admin验收快递-${suffix}`, servicePhone: '4000000000',
website: 'https://example.com', trackingUrlTemplate: 'https://example.com/{trackingNo}', status: 'ACTIVE', remark: context.marker,
}
const createExpressResponse = await context.request('POST', '/api/platform/v1/express-companies?tenantId=0', expressPayload)
const expressId = id(context.apiSuccess(module, '创建快递公司', createExpressResponse))
if (!expressId) throw new Error('快递公司创建失败')
context.createdResources.expressCompany = { id: expressId, code: expressPayload.companyCode, finalStatus: 'INACTIVE' }
const expressPath = `/api/platform/v1/express-companies/${expressId}?tenantId=0`
const expressDetailResponse = await context.request('GET', expressPath)
const express = context.apiSuccess(module, '查询快递公司详情', expressDetailResponse)
context.assert(module, '快递公司详情正确', expressDetailResponse, id(express?.id) === expressId && express?.companyCode === expressPayload.companyCode, `expressId=${expressId}`)
const updateExpressResponse = await context.request('PUT', expressPath, { ...expressPayload, companyName: `${expressPayload.companyName}-已编辑`, remark: `${context.marker}-EDITED` })
context.apiSuccess(module, '编辑快递公司', updateExpressResponse)
const disableExpressResponse = await context.request('PATCH', `/api/platform/v1/express-companies/${expressId}/status?tenantId=0`, { status: 'INACTIVE' })
context.apiSuccess(module, '停用快递公司', disableExpressResponse)
const expressListResponse = await context.request('GET', `/api/platform/v1/express-companies?page=1&size=20&keyword=${expressPayload.companyCode}`)
const expressPage = context.apiSuccess(module, '筛选快递公司列表', expressListResponse)
context.assert(module, '快递公司列表回显停用状态', expressListResponse,
expressPage?.records?.some((item) => id(item.id) === expressId && item.status === 'INACTIVE'), `records=${expressPage?.records?.length ?? 'n/a'}`)
const partnerPayload = {
partnerCode: `PT${suffix}`, partnerName: `Admin验收往来单位-${suffix}`, partnerType: 'SUPPLIER', types: ['SUPPLIER'],
contactName: '往来单位验收员', contactPhone: '13800000002', contactEmail: `partner-${suffix}@example.com`,
countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区', addressLine: 'Admin API 往来单位地址',
postalCode: '518000', status: 'ACTIVE', remark: context.marker,
}
const createPartnerResponse = await context.request('POST', '/api/platform/v1/partners?tenantId=0', partnerPayload)
const partnerId = id(context.apiSuccess(module, '创建往来单位', createPartnerResponse))
if (!partnerId) throw new Error('往来单位创建失败')
context.createdResources.partner = { id: partnerId, code: partnerPayload.partnerCode, finalStatus: 'INACTIVE' }
const partnerPath = `/api/platform/v1/partners/${partnerId}?tenantId=0`
const partnerDetailResponse = await context.request('GET', partnerPath)
const partner = context.apiSuccess(module, '查询往来单位详情', partnerDetailResponse)
context.assert(module, '往来单位详情正确', partnerDetailResponse,
id(partner?.id) === partnerId && partner?.types?.includes('SUPPLIER'), `partnerId=${partnerId}`)
const updatePartnerResponse = await context.request('PUT', partnerPath, { ...partnerPayload, partnerName: `${partnerPayload.partnerName}-已编辑`, types: ['SUPPLIER', 'CARRIER'], remark: `${context.marker}-EDITED` })
context.apiSuccess(module, '编辑往来单位', updatePartnerResponse)
const disablePartnerResponse = await context.request('PATCH', `/api/platform/v1/partners/${partnerId}/status?tenantId=0`, { status: 'INACTIVE' })
context.apiSuccess(module, '停用往来单位', disablePartnerResponse)
const partnerListResponse = await context.request('GET', `/api/platform/v1/partners?page=1&size=20&keyword=${partnerPayload.partnerCode}`)
const partnerPage = context.apiSuccess(module, '筛选往来单位列表', partnerListResponse)
context.assert(module, '往来单位列表回显编辑与停用状态', partnerListResponse,
partnerPage?.records?.some((item) => id(item.id) === partnerId && item.status === 'INACTIVE' && item.types?.includes('CARRIER')),
`records=${partnerPage?.records?.length ?? 'n/a'}`)
const templates = [
['仓库', '/api/platform/v1/warehouse-admin/import-template'],
['快递公司', '/api/platform/v1/express-companies/import-template'],
['往来单位', '/api/platform/v1/partners/import-template'],
]
for (const [name, path] of templates) {
const response = await context.requestBinary('GET', path)
context.addStep(module, `下载${name}导入模板`, response, xlsxOk(response),
`bytes=${response.bytes?.length ?? 0}, contentType=${response.contentType || 'missing'}`)
}
const disableWarehouseResponse = await context.request('PATCH', `/api/platform/v1/warehouse-admin/${warehouseId}/status?tenantId=0&status=INACTIVE`)
context.apiSuccess(module, '停用测试仓库', disableWarehouseResponse)
}
+391
View File
@@ -0,0 +1,391 @@
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 BOXES = [
{ boxType: 'SMALL', lengthCm: 30, widthCm: 20, heightCm: 20 },
{ boxType: 'MEDIUM', lengthCm: 40, widthCm: 30, heightCm: 30 },
{ boxType: 'LARGE', lengthCm: 50, widthCm: 40, heightCm: 40 },
{ boxType: 'EXTRA_LARGE', lengthCm: 60, widthCm: 50, heightCm: 50 },
]
const MEASURABLE_STATUSES = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
function id(value) { return value == null ? '' : String(value) }
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite(Number(value?.total)) }
function success(response) { return response.http === 200 && String(response.body?.code) === '200' }
function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) }
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
function tomorrow(days = 30) {
const date = new Date(); date.setUTCDate(date.getUTCDate() + days)
return date.toISOString().slice(0, 10)
}
async function request(context, method, path, body, token, extraHeaders = {}) {
const headers = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...extraHeaders }
let payload = body
if (body !== undefined && !(body instanceof FormData)) {
headers['Content-Type'] = 'application/json; charset=utf-8'
payload = JSON.stringify(body)
}
try {
const response = await fetch(`${context.options.gateway}${path}`, {
method, headers, body: payload, signal: AbortSignal.timeout(30_000),
})
const content = await response.text()
let parsed = null
try { parsed = content ? JSON.parse(content) : null } catch { /* Recorded as a bounded raw response. */ }
return {
method, path, http: response.status, body: parsed,
raw: parsed === null ? content.slice(0, 500) : null,
requestId: parsed?.requestId || response.headers.get('x-request-id'),
traceId: parsed?.traceId || response.headers.get('x-trace-id'),
}
} catch (error) {
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
}
}
async function uploadProductImage(context, merchantToken) {
const form = new FormData()
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
form.append('file', new Blob([png], { type: 'image/png' }), `product-${context.marker}.png`)
const response = await request(context, 'POST', '/api/merchant/v1/files/product-images', form, merchantToken)
const data = context.apiSuccess(MODULE, 'Client 上传产品图片', response, 'contentType=image/png', CONTRACT_OPTIONS)
context.assert(MODULE, '产品图片上传返回字符串文件 ID', response,
Boolean(data?.fileId) && typeof data.fileId === 'string' && data.contentType === 'image/png',
`fileIdType=${typeof data?.fileId}, contentType=${data?.contentType ?? 'missing'}`, CONTRACT_OPTIONS)
return id(data?.fileId)
}
async function assertProductImageDownload(context, merchantToken, fileId) {
const path = `/api/merchant/v1/files/product-images/${encodeURIComponent(fileId)}`
try {
const response = await fetch(`${context.options.gateway}${path}`, {
headers: { Accept: 'image/*', Authorization: `Bearer ${merchantToken}` },
signal: AbortSignal.timeout(30_000),
})
const bytes = await response.arrayBuffer()
const evidence = { method: 'GET', path, http: response.status, body: null, raw: null,
requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') }
context.assert(MODULE, 'Client 可下载本轮产品图片', evidence,
response.status === 200 && bytes.byteLength > 0 && (response.headers.get('content-type') || '').startsWith('image/'),
`bytes=${bytes.byteLength}, contentType=${response.headers.get('content-type') || 'missing'}`, CONTRACT_OPTIONS)
} catch (error) {
context.assert(MODULE, 'Client 可下载本轮产品图片', null, false, String(error), CONTRACT_OPTIONS)
}
}
async function loadMasterData(context, merchantToken) {
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 fbaResponse = await request(context, 'GET',
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', undefined, merchantToken)
const fbaPage = context.apiSuccess(MODULE, 'Client 查询 FBA 目的仓', fbaResponse)
context.assert(MODULE, '存在可用 FBA 目的仓', fbaResponse, records(fbaPage).length > 0,
`count=${records(fbaPage).length}`)
const receiving = records(domesticPage)[0]; const destination = records(fbaPage)[0]
if (!receiving || !destination) throw new Error('缺少创建测试订单所需的收货仓或目的仓')
const channelResponse = await request(context, 'GET',
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`,
undefined, merchantToken)
const channels = context.apiSuccess(MODULE, 'Client 查询可用计费渠道', channelResponse)
const channel = Array.isArray(channels)
? channels.find((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0)
: null
context.assert(MODULE, '存在带计价税制的可用渠道', channelResponse, Boolean(channel),
`count=${Array.isArray(channels) ? channels.length : 'n/a'}`)
if (!channel) throw new Error('缺少创建测试订单所需的可用渠道')
const partnerResponse = await request(context, 'GET',
'/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken)
const partners = context.apiSuccess(MODULE, 'Client 查询发货客户候选', partnerResponse)
const partner = Array.isArray(partners)
? partners.find((item) => item.status === 'ACTIVE' && item.types?.includes?.('SHIPPER'))
: null
return { receiving, destination, channel, partner }
}
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}`
return {
customerId: master.partner?.id ?? null,
customerName: master.partner?.partnerName ?? companyName,
transportMode: 'SEA', tradeTerm: 'FOB',
destinationCountryCode: master.destination.countryCode || 'US',
destinationWarehouseId: master.destination.id,
destinationWarehouseName: master.destination.warehouseName,
expectedShipDate: tomorrow(), customerRemark: `${context.marker} Client 创建隔离订单`,
pickupMethod: 'PICKUP', 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.channel.pricingTaxModes[0], containerMode: 'LCL',
customsInspection: false, destinationType: 'FBA',
amazonWarehouseCode: master.destination.warehouseCode,
shipmentId, referenceId,
sender: {
companyName, contactName: '订单验收联系人', phone: '13800138000',
email: 'order-acceptance@example.com', countryCode: 'CN', province: '广东省',
city: '深圳市', district: '南山区', addressLine: `${context.marker} 测试地址`, postalCode: '518000',
},
receiver: null,
cargoes: BOXES.map((box, index) => {
const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000
const unitsPerPackage = 10 + index
return {
skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`,
hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON',
lengthCm: box.lengthCm, widthCm: box.widthCm, heightCm: box.heightCm,
unitsPerPackage, packageCount: 1, grossWeightKg: 10 + index,
volumeCbm, totalQuantity: unitsPerPackage, netWeightKg: 9 + index,
unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * 8.5, currency: 'USD',
material: '塑料 / Plastic', usage: '接口验收 / API acceptance',
model: `MODEL-${box.boxType}`, brand: 'TEST-NO-LOGO',
productLengthCm: 12 + index, productWidthCm: 8 + index, productHeightCm: 6 + index,
shipmentId, referenceId, upcNo: `${Date.now()}${index}`.slice(-12),
productImageFileId, dangerousGoods: false,
cargoRemark: `${context.marker} boxType=${box.boxType}`,
}
}),
}
}
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)
&& actual?.skuNo === expected.skuNo && actual?.cargoName === expected.cargoName
&& actual?.cargoNameEn === expected.cargoNameEn && actual?.customsHsCode === expected.customsHsCode
&& Number(actual?.unitsPerPackage) === expected.unitsPerPackage
&& Number(actual?.packageCount) === 1 && Number(actual?.totalQuantity) === expected.totalQuantity
&& Number(actual?.netWeightKg) === expected.netWeightKg
&& Number(actual?.unitDeclaredValue) === expected.unitDeclaredValue
&& actual?.material === expected.material && actual?.productUsage === expected.usage
&& actual?.model === expected.model && actual?.brand === expected.brand
&& Number(actual?.productLengthCm) === expected.productLengthCm
&& Number(actual?.productWidthCm) === expected.productWidthCm
&& Number(actual?.productHeightCm) === expected.productHeightCm
&& actual?.shipmentId === expected.shipmentId && actual?.referenceId === expected.referenceId
&& actual?.upcNo === expected.upcNo && actual?.cargoRemark.includes(context.marker)
})
context.assert(MODULE, '四箱型产品字段及图片契约完整回显', response, complete,
`cargoes=${Array.isArray(cargoes) ? cargoes.length : 'n/a'}, imageFileId=${payload.cargoes[0].productImageFileId}`,
CONTRACT_OPTIONS)
}
async function assertDimensionConfig(context, tenantId) {
const path = `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${encodeURIComponent(tenantId)}`
const response = await context.request('GET', path)
const data = context.apiSuccess(MODULE, 'Admin 查询客户固定箱型', response, `tenantId=${tenantId}`, CONTRACT_OPTIONS)
const rules = Array.isArray(data?.rules) ? data.rules : []
const exact = BOXES.every((box, index) => {
const rule = rules[index]
return rule?.boxType === box.boxType && Number(rule.innerLengthCm) === box.lengthCm
&& Number(rule.innerWidthCm) === box.widthCm && Number(rule.innerHeightCm) === box.heightCm
&& Number(rule.edgeAdditionCm) === 2
})
context.assert(MODULE, '固定四箱型顺序、内径及每边加2cm正确', response,
rules.length === BOXES.length && exact,
`types=${rules.map((rule) => rule.boxType).join(',') || 'missing'}`, CONTRACT_OPTIONS)
}
async function waitForInbound(context, tenantId, orderId) {
const path = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
let response = null; let detail = null
for (let attempt = 1; attempt <= 20; attempt += 1) {
response = await context.request('GET', path)
if (success(response) && response.body?.data?.summary?.inboundId) {
detail = response.body.data
break
}
if (attempt < 20) await sleep(1_000)
}
context.addStep(MODULE, '审核后生成隔离入库单', response, Boolean(detail),
detail ? `inboundId=${detail.summary.inboundId}` : '20秒内未返回 inboundId', CONTRACT_OPTIONS)
return { response, detail }
}
export async function run(context) {
const loginResponse = await context.request('POST', '/api/auth/login', {
username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
}, '')
const login = context.apiSuccess(MODULE, 'Client 测试账号登录', loginResponse,
`username=${MERCHANT_USERNAME}`)
const merchantToken = login?.accessToken
if (!merchantToken) throw new Error('Client 登录未返回 accessToken')
const profileResponse = await request(context, 'GET', '/api/merchant/v1/iam/tenant/profile',
undefined, merchantToken)
const profile = context.apiSuccess(MODULE, 'Client 查询当前企业资料', profileResponse)
const tenantId = id(profile?.id)
context.assert(MODULE, 'Client 测试企业已审核且客户 ID 有效', profileResponse,
Boolean(tenantId) && profile?.status === 'ACTIVE', `tenantId=${tenantId || 'missing'}, status=${profile?.status ?? 'missing'}`)
if (!tenantId) throw new Error('Client 企业资料未返回客户 ID')
await assertDimensionConfig(context, tenantId)
const master = await loadMasterData(context, merchantToken)
const productImageFileId = await uploadProductImage(context, merchantToken)
if (!productImageFileId) throw new Error('产品图片新契约未部署,无法创建带图片的隔离订单')
await assertProductImageDownload(context, merchantToken, productImageFileId)
const payload = buildPayload(context, profile, master, productImageFileId)
const createPath = '/api/merchant/v1/orders/first-leg'
const createResponse = await request(context, 'POST', createPath, payload, merchantToken,
{ 'Idempotency-Key': key(context, 'client-create') })
const orderId = id(context.apiSuccess(MODULE, 'Client 创建四箱型隔离订单', createResponse,
`marker=${context.marker}`, CONTRACT_OPTIONS))
if (!orderId) throw new Error('Client 订单创建失败,后续 Admin 链路停止')
context.createdResources.orders = {
marker: context.marker, tenantId, orderId, productImageFileId,
boxTypes: BOXES.map((box) => box.boxType), inboundId: null,
}
const replayResponse = await request(context, 'POST', createPath, payload, merchantToken,
{ 'Idempotency-Key': key(context, 'client-create') })
const replayedId = id(context.apiSuccess(MODULE, 'Client 创建订单幂等重放', replayResponse))
context.assert(MODULE, 'Client 创建幂等结果一致', replayResponse, replayedId === orderId,
`orderId=${orderId}, replayedId=${replayedId}`)
const clientDetailPath = `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}`
const draftResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken)
const draft = context.apiSuccess(MODULE, 'Client 查询隔离订单草稿', draftResponse)
const orderNo = id(draft?.order?.orderNo)
context.assert(MODULE, 'Client 草稿身份、marker 和状态正确', draftResponse,
id(draft?.order?.id) === orderId && draft?.order?.status === 'DRAFT'
&& String(draft?.order?.customerRemark || '').includes(context.marker),
`orderNo=${orderNo || 'missing'}, status=${draft?.order?.status ?? 'missing'}`)
assertCargoContract(context, draftResponse, draft?.cargoes, payload)
context.createdResources.orders.orderNo = orderNo
const submitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined,
merchantToken, { 'Idempotency-Key': key(context, 'client-submit') })
context.apiSuccess(MODULE, 'Client 提交隔离订单', submitResponse, `orderId=${orderId}`)
const submittedResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken)
const submitted = context.apiSuccess(MODULE, 'Client 回查已提交订单', submittedResponse)
context.assert(MODULE, 'Client 订单已进入待审核状态', submittedResponse,
submitted?.order?.status === 'SUBMITTED', `status=${submitted?.order?.status ?? 'missing'}`)
const unifiedListResponse = await context.request('GET',
`/api/platform/v1/orders?page=1&size=100&tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}`)
const unifiedPage = context.apiSuccess(MODULE, 'Admin 查询统一订单列表', unifiedListResponse)
const unifiedRow = records(unifiedPage).find((row) => id(row.orderId) === orderId)
context.assert(MODULE, 'Admin 统一订单列表只核对本轮订单', unifiedListResponse,
pageOk(unifiedPage) && Boolean(unifiedRow), `orderId=${orderId}, found=${Boolean(unifiedRow)}`)
const unifiedDetailPath = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
const unifiedDetailResponse = await context.request('GET', unifiedDetailPath)
const unifiedDetail = context.apiSuccess(MODULE, 'Admin 查询统一订单详情', unifiedDetailResponse)
context.assert(MODULE, 'Admin 统一订单详情身份与待审核状态正确', unifiedDetailResponse,
id(unifiedDetail?.summary?.orderId) === orderId && unifiedDetail?.summary?.mainStatus === 'PENDING_REVIEW'
&& Array.isArray(unifiedDetail?.timeline),
`mainStatus=${unifiedDetail?.summary?.mainStatus ?? 'missing'}, timeline=${unifiedDetail?.timeline?.length ?? 'n/a'}`)
const firstLegListResponse = await context.request('GET',
`/api/platform/v1/orders/first-leg?tenantId=${encodeURIComponent(tenantId)}&keyword=${encodeURIComponent(orderNo)}&page=1&size=100`)
const firstLegPage = context.apiSuccess(MODULE, 'Admin 查询头程订单列表', firstLegListResponse)
const firstLegRow = records(firstLegPage).find((row) => id(row.id) === orderId)
context.assert(MODULE, 'Admin 头程订单列表只核对本轮订单', firstLegListResponse,
pageOk(firstLegPage) && Boolean(firstLegRow), `orderId=${orderId}, found=${Boolean(firstLegRow)}`)
const firstLegDetailPath = `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
const firstLegDetailResponse = await context.request('GET', firstLegDetailPath)
const firstLegDetail = context.apiSuccess(MODULE, 'Admin 查询头程订单详情', firstLegDetailResponse)
context.assert(MODULE, 'Admin 头程订单详情聚合结构完整', firstLegDetailResponse,
id(firstLegDetail?.order?.id) === orderId && Array.isArray(firstLegDetail?.cargoes)
&& Array.isArray(firstLegDetail?.parties) && Array.isArray(firstLegDetail?.internalNotes)
&& Array.isArray(firstLegDetail?.statusHistory),
`cargoes=${firstLegDetail?.cargoes?.length ?? 'n/a'}, history=${firstLegDetail?.statusHistory?.length ?? 'n/a'}`)
assertCargoContract(context, firstLegDetailResponse, firstLegDetail?.cargoes, payload)
const noteResponse = await context.request('POST',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/internal-notes?tenantId=${encodeURIComponent(tenantId)}`,
{ content: `${context.marker} Admin 订单链路验收` })
context.apiSuccess(MODULE, 'Admin 为本轮订单添加内部备注', noteResponse)
const approveResponse = await context.request('POST',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/approve?tenantId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, 'Admin 审核通过本轮订单', approveResponse, `orderId=${orderId}`)
const inboundResult = await waitForInbound(context, tenantId, orderId)
const inboundId = id(inboundResult.detail?.summary?.inboundId)
if (!inboundId) throw new Error('订单审核后的入库单契约未部署,无法向 OCR/墨家模块交接')
const inboundPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`
const inboundResponse = await context.request('GET', inboundPath)
const inbound = context.apiSuccess(MODULE, 'Admin 查询本轮入库单详情', inboundResponse,
`inboundId=${inboundId}`, CONTRACT_OPTIONS)
const markerValues = [
inbound?.publicRemark, inbound?.summary?.orderNo, inbound?.summary?.inboundCode,
...(Array.isArray(inbound?.internalNotes) ? inbound.internalNotes.map((note) => note.content) : []),
]
const markerFound = markerValues.some((value) => String(value || '').includes(context.marker))
const measurable = MEASURABLE_STATUSES.has(inbound?.summary?.processStatus)
context.assert(MODULE, '隔离入库单 marker 来源与可测量状态正确', inboundResponse,
id(inbound?.summary?.id) === inboundId && id(inbound?.summary?.tenantId) === tenantId
&& markerFound && measurable,
`markerFound=${markerFound}, status=${inbound?.summary?.processStatus ?? 'missing'}`, CONTRACT_OPTIONS)
context.assert(MODULE, '入库单详情包含托盘库位、文件、备注和状态历史', inboundResponse,
Array.isArray(inbound?.palletLocations) && Array.isArray(inbound?.files)
&& Array.isArray(inbound?.internalNotes) && Array.isArray(inbound?.statusHistory),
`inboundId=${inboundId}, history=${inbound?.statusHistory?.length ?? 'n/a'}`)
const inboundListResponse = await context.request('GET',
`/api/platform/inbounds?tenantId=${encodeURIComponent(tenantId)}&inboundCode=${encodeURIComponent(inbound.summary.inboundCode)}&page=1&size=100`)
const inboundPage = context.apiSuccess(MODULE, 'Admin 查询入库单列表', inboundListResponse)
context.assert(MODULE, 'Admin 入库单列表只核对本轮入库单', inboundListResponse,
pageOk(inboundPage) && records(inboundPage).some((row) => id(row.id) === inboundId),
`inboundId=${inboundId}, total=${inboundPage?.total ?? 'n/a'}`)
context.createdResources.orders.inboundId = inboundId
context.createdResources.orders.inboundCode = inbound.summary.inboundCode
context.createdResources.orders.processStatus = inbound.summary.processStatus
context.createdResources.ocrInbound = { tenantId, inboundId, marker: context.marker }
context.createdResources.testInbound = { tenantId, inboundId, marker: context.marker }
context.context.inboundWeightOcr = { tenantId, inboundId, marker: context.marker }
context.context.ocrInbound = context.context.inboundWeightOcr
context.context.testInbound = context.context.inboundWeightOcr
context.context.createdInbound = context.context.inboundWeightOcr
const taskResponse = await context.request('GET',
`/api/platform/v1/first-leg/tasks/by-order/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`)
const task = context.apiSuccess(MODULE, 'Admin 查询本轮订单履约任务', taskResponse,
'新订单应生成可查询的履约任务', CONTRACT_OPTIONS)
context.assert(MODULE, '履约任务与本轮订单和客户一致', taskResponse,
id(task?.orderId) === orderId && id(task?.tenantId) === tenantId,
`taskId=${id(task?.id) || 'missing'}, status=${task?.status ?? 'missing'}`, CONTRACT_OPTIONS)
if (task?.id) {
context.createdResources.orders.taskId = id(task.id)
const historyResponse = await context.request('GET',
`/api/platform/v1/first-leg/tasks/${encodeURIComponent(task.id)}/assignment-history?tenantId=${encodeURIComponent(tenantId)}&page=1&size=20`)
const history = context.apiSuccess(MODULE, 'Admin 查询本轮履约任务分配历史', historyResponse)
context.assert(MODULE, '履约分配历史分页结构正确', historyResponse, pageOk(history),
`taskId=${task.id}, total=${history?.total ?? 'n/a'}`)
}
const acceptResponse = await context.request('POST',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/accept?tenantId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, 'Admin 受理本轮订单', acceptResponse)
const startResponse = await context.request('POST',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/start?tenantId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, 'Admin 启动本轮订单履约', startResponse)
const finalDetailResponse = await context.request('GET', firstLegDetailPath)
const finalDetail = context.apiSuccess(MODULE, 'Admin 回查本轮订单履约状态', finalDetailResponse)
context.assert(MODULE, '本轮订单已进入处理中且保留状态历史', finalDetailResponse,
finalDetail?.order?.status === 'PROCESSING'
&& finalDetail?.statusHistory?.some?.((item) => item.toStatus === 'APPROVED')
&& finalDetail?.statusHistory?.some?.((item) => item.toStatus === 'ACCEPTED')
&& finalDetail?.statusHistory?.some?.((item) => item.toStatus === 'PROCESSING'),
`status=${finalDetail?.order?.status ?? 'missing'}, history=${finalDetail?.statusHistory?.length ?? 'n/a'}`)
}
+314
View File
@@ -0,0 +1,314 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { basename } from 'node:path'
const PRICE_BOOK_PATH = '/api/platform/v1/fba-pricing/carrier-rates/price-books'
const DOMESTIC_WAREHOUSE_PATH = '/api/platform/v1/warehouse-admin?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC'
const OVERSEAS_WAREHOUSE_PATH = '/api/platform/v1/warehouse-admin?page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS'
const DEFAULT_PRICE_BOOK_FILE = 'C:\\Users\\BY-OY\\Documents\\WXWork\\1688854456873012\\Cache\\File\\2026-08\\2026.08.03柏悦价格表 船期2026.08.12.xlsx'
const EXPECTED_CHANNEL_CODES = [
'MATSON_CLX_TRUCK', 'MATSON_MAX_TRUCK', 'EXX_TRUCK', 'HEDE_TRUCK', 'EXX_RAIL_NY',
'OA_LA_TRUCK', 'OA_LA_FULL_TRUCK', 'OA_CHICAGO_RAIL', 'OA_HOUSTON_TRUCK',
'OA_NEW_YORK_TRUCK', 'OA_SAVANNAH_TRUCK', 'MATSON_CLX_PARCEL',
'MATSON_MAX_PARCEL', 'HEDE_PARCEL', 'OA_PARCEL',
]
const MAPPING_ISSUE_CODES = new Set([
'ORIGIN_MAPPING_REQUIRED',
'ORIGIN_WAREHOUSE_INVALID',
'OVERSEAS_MAPPING_REQUIRED',
'OVERSEAS_WAREHOUSE_INVALID',
'WAREHOUSE_VALIDATION_FAILED',
'DUPLICATE_ORIGIN_MAPPING',
'DUPLICATE_OVERSEAS_MAPPING',
'UNUSED_ORIGIN_MAPPING',
'UNUSED_OVERSEAS_MAPPING',
'DUPLICATE_WAREHOUSE_MAPPING',
'DUPLICATE_RATE',
])
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function normalize(value) {
return String(value || '').trim().toUpperCase()
}
function validId(value) {
return typeof value === 'string' && /^[1-9][0-9]*$/.test(value)
}
function finishScenario(context, module, scenario, start) {
const steps = context.steps.slice(start)
context.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 function uploadWorkbook(context, bytes, fileName) {
const form = new FormData()
form.append('file', new Blob([bytes], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}), fileName)
try {
const response = await fetch(`${context.options.gateway}${PRICE_BOOK_PATH}`, {
method: 'POST',
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
body: form,
signal: AbortSignal.timeout(120_000),
})
const content = await response.text()
let payload = null
try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is diagnostic evidence. */ }
return {
method: 'POST', path: PRICE_BOOK_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: 'POST', path: PRICE_BOOK_PATH, http: 0, body: null,
raw: error instanceof Error ? error.message : String(error),
requestId: null, traceId: null,
}
}
}
function buildMappings(detail, warehouses) {
const domestic = warehouses.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC')
const overseas = warehouses.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'OVERSEAS')
const originSources = new Map()
const overseasSources = new Map()
for (const rate of detail.rates || []) {
const originCode = String(rate.originWarehouseCode || '').trim()
if (originCode && !originSources.has(normalize(originCode))) originSources.set(normalize(originCode), originCode)
if (rate.destinationType === 'OVERSEAS' && String(rate.destinationName || '').trim()) {
const sourceName = String(rate.destinationName).trim()
const key = normalize(sourceName)
if (!overseasSources.has(key)) overseasSources.set(key, {
sourceName,
destinationCode: String(rate.destinationCode || '').trim(),
})
}
}
const originMappings = []
const missingOrigins = []
for (const sourceCode of originSources.values()) {
const matches = domestic.filter((warehouse) => normalize(warehouse.warehouseCode) === normalize(sourceCode))
if (matches.length === 1) originMappings.push({ originWarehouseCode: sourceCode, warehouseId: String(matches[0].id) })
else missingOrigins.push(`${sourceCode}:${matches.length}`)
}
const overseasMappings = []
const missingOverseas = []
for (const source of overseasSources.values()) {
const matches = overseas.filter((warehouse) => (
source.destinationCode
? normalize(warehouse.warehouseCode) === normalize(source.destinationCode)
: normalize(warehouse.warehouseName) === normalize(source.sourceName)
))
if (matches.length === 1) overseasMappings.push({ sourceDestinationName: source.sourceName, warehouseId: String(matches[0].id) })
else missingOverseas.push(`${source.sourceName}/${source.destinationCode || '-'}:${matches.length}`)
}
return { originMappings, overseasMappings, missingOrigins, missingOverseas, originSources, overseasSources }
}
export async function run(context) {
const module = 'pricing'
const scenario = 'price-book-lifecycle'
const start = context.steps.length
const blocker = { scenario, deploymentBlocker: true }
const listBeforeResponse = await context.request('GET', PRICE_BOOK_PATH)
const versionsBefore = context.apiSuccess(module, '查询发布前船期价格版本', listBeforeResponse, '', blocker)
const activeBefore = Array.isArray(versionsBefore) ? versionsBefore.filter((version) => version.status === 'ACTIVE') : []
context.assert(module, '发布前最多一份生效版本', listBeforeResponse,
Array.isArray(versionsBefore) && activeBefore.length <= 1,
`versions=${Array.isArray(versionsBefore) ? versionsBefore.length : 'n/a'}, active=${activeBefore.length}`, blocker)
const sourcePath = process.env.ADMIN_TEST_PRICE_BOOK_FILE || DEFAULT_PRICE_BOOK_FILE
let sourceBytes
try {
sourceBytes = await readFile(sourcePath)
} catch (error) {
context.addStep(module, '读取船期价格测试文件', null, false,
`path=${sourcePath}, error=${error instanceof Error ? error.message : String(error)}`, blocker)
finishScenario(context, module, scenario, start)
return
}
const sourceHash = sha256(sourceBytes)
const uploadName = `${context.marker}-${basename(sourcePath)}`
context.addStep(module, '船期价格测试文件已隔离', null,
sourceBytes.length > 0 && uploadName.includes(context.marker) && uploadName.toLowerCase().endsWith('.xlsx'),
`fileName=${uploadName}, bytes=${sourceBytes.length}, sha256=${sourceHash}`, blocker)
const uploadResponse = await uploadWorkbook(context, sourceBytes, uploadName)
const uploaded = context.apiSuccess(module, '上传并解析船期价格表', uploadResponse,
`fileName=${uploadName}, bytes=${sourceBytes.length}, sha256=${sourceHash}`, blocker)
if (!uploaded?.version) {
finishScenario(context, module, scenario, start)
return
}
const versionId = String(uploaded.version.id || '')
context.createdResources.priceBookVersion = { versionId, marker: context.marker, sourceSha256: sourceHash }
context.assert(module, '上传返回隔离的待确认版本', uploadResponse,
validId(versionId)
&& uploaded.version.status === 'PENDING_CONFIRMATION'
&& uploaded.version.originalFileName === uploadName
&& uploaded.version.sourceSha256 === sourceHash,
`versionId=${versionId}, status=${uploaded.version.status}, fileName=${uploaded.version.originalFileName}, shaMatched=${uploaded.version.sourceSha256 === sourceHash}`, blocker)
const channels = Array.isArray(uploaded.channels) ? uploaded.channels : []
const rates = Array.isArray(uploaded.rates) ? uploaded.rates : []
const issues = Array.isArray(uploaded.issues) ? uploaded.issues : []
const channelCodes = channels.map((channel) => String(channel.channelCode))
const channelSet = new Set(channelCodes)
const expectedChannels = EXPECTED_CHANNEL_CODES.every((code) => channelSet.has(code))
context.assert(module, '解析出15个固定渠道且编码唯一', uploadResponse,
channels.length === EXPECTED_CHANNEL_CODES.length && channelSet.size === channels.length && expectedChannels
&& Number(uploaded.version.channelCount) === channels.length,
`channelCount=${channels.length}, unique=${channelSet.size}, missing=${EXPECTED_CHANNEL_CODES.filter((code) => !channelSet.has(code)).join(',') || 'none'}`, blocker)
const ratesValid = rates.length > 0 && rates.every((rate) => (
channelSet.has(String(rate.channelCode))
&& ['FBA_WAREHOUSE_RATE', 'REGION_WEIGHT_TIER'].includes(String(rate.priceModel))
&& String(rate.originWarehouseCode || '').trim()
&& ['KG', 'CBM'].includes(String(rate.chargeUnit))
&& Number(rate.unitPrice) > 0
&& /^[A-Z]{3}$/.test(String(rate.currency || ''))
&& String(rate.sourceSheetName || '').trim()
&& Number(rate.sourceRowNumber) >= 1
))
context.assert(module, '解析价格行完整且可追溯到 Excel', uploadResponse,
ratesValid && Number(uploaded.version.rateCount) === rates.length,
`rateCount=${rates.length}, declared=${uploaded.version.rateCount}, valid=${ratesValid}`, blocker)
const blockingIssues = issues.filter((issue) => issue.severity === 'BLOCKING')
const unexpectedBlocking = blockingIssues.filter((issue) => !MAPPING_ISSUE_CODES.has(issue.issueCode))
const infrastructureIssue = issues.find((issue) => issue.issueCode === 'UPLOAD_OR_PARSE_FAILED'
|| /SQLSyntaxError|bad SQL grammar|Service Unavailable/i.test(String(issue.message || '')))
context.assert(module, '解析阻断仅包含可确认的仓库映射问题', uploadResponse,
!infrastructureIssue && unexpectedBlocking.length === 0
&& Number(uploaded.version.blockingErrorCount) === blockingIssues.length,
`blocking=${blockingIssues.length}, unexpected=${[...new Set(unexpectedBlocking.map((issue) => issue.issueCode))].join(',') || 'none'}, infrastructure=${infrastructureIssue?.issueCode || 'none'}`, blocker)
const listPendingResponse = await context.request('GET', PRICE_BOOK_PATH)
const versionsPending = context.apiSuccess(module, '上传后回查价格版本列表', listPendingResponse, '', blocker)
const pendingEntry = versionsPending?.find?.((version) => String(version.id) === versionId)
const activeAfterUpload = versionsPending?.filter?.((version) => version.status === 'ACTIVE') || []
context.assert(module, '上传只生成待确认版本且不替换当前生效版本', listPendingResponse,
pendingEntry?.status === 'PENDING_CONFIRMATION'
&& activeAfterUpload.length === activeBefore.length
&& activeBefore.every((before) => activeAfterUpload.some((after) => String(after.id) === String(before.id))),
`pending=${pendingEntry?.status || 'missing'}, activeBefore=${activeBefore.map((item) => item.id).join(',') || 'none'}, activeAfterUpload=${activeAfterUpload.map((item) => item.id).join(',') || 'none'}`, blocker)
const detailResponse = await context.request('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}`)
const detail = context.apiSuccess(module, '查询待确认版本渠道和价格预览', detailResponse, '', blocker)
context.assert(module, '详情与上传解析结果一致', detailResponse,
String(detail?.version?.id) === versionId
&& detail?.version?.status === 'PENDING_CONFIRMATION'
&& detail?.channels?.length === channels.length
&& detail?.rates?.length === rates.length
&& detail?.issues?.length === issues.length,
`versionId=${detail?.version?.id || 'missing'}, channels=${detail?.channels?.length ?? 'n/a'}, rates=${detail?.rates?.length ?? 'n/a'}, issues=${detail?.issues?.length ?? 'n/a'}`, blocker)
const sourceResponse = await context.requestBinary('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}/source`)
const downloadedHash = sourceResponse.bytes?.length ? sha256(sourceResponse.bytes) : ''
const zipMagic = sourceResponse.bytes?.[0] === 0x50 && sourceResponse.bytes?.[1] === 0x4b
context.addStep(module, '下载原始 XLSX 且内容散列一致', sourceResponse,
sourceResponse.http === 200 && sourceResponse.bytes?.length === sourceBytes.length
&& zipMagic && downloadedHash === sourceHash
&& !sourceResponse.contentType.toLowerCase().includes('json'),
`bytes=${sourceResponse.bytes?.length ?? 0}, contentType=${sourceResponse.contentType || 'missing'}, zipMagic=${zipMagic}, sha256=${downloadedHash}`, blocker)
if (!detail || unexpectedBlocking.length > 0 || infrastructureIssue) {
finishScenario(context, module, scenario, start)
return
}
const domesticResponse = await context.request('GET', DOMESTIC_WAREHOUSE_PATH)
const domesticPage = context.apiSuccess(module, '查询启用的平台公共国内仓', domesticResponse, '', blocker)
const overseasResponse = await context.request('GET', OVERSEAS_WAREHOUSE_PATH)
const overseasPage = context.apiSuccess(module, '查询启用的平台公共海外仓', overseasResponse, '', blocker)
const warehouses = [
...(Array.isArray(domesticPage?.records) ? domesticPage.records : []),
...(Array.isArray(overseasPage?.records) ? overseasPage.records : []),
]
const mappings = buildMappings(detail, warehouses)
const mappingsComplete = mappings.originSources.size > 0
&& mappings.originMappings.length === mappings.originSources.size
&& mappings.overseasMappings.length === mappings.overseasSources.size
&& mappings.missingOrigins.length === 0
&& mappings.missingOverseas.length === 0
context.assert(module, '国内起运仓和目的海外仓均精确映射', overseasResponse,
mappingsComplete,
`origins=${mappings.originMappings.map((item) => `${item.originWarehouseCode}->${item.warehouseId}`).join(',') || 'none'}, overseas=${mappings.overseasMappings.map((item) => `${item.sourceDestinationName}->${item.warehouseId}`).join(',') || 'none'}, missing=${[...mappings.missingOrigins, ...mappings.missingOverseas].join(',') || 'none'}`, blocker)
if (!mappingsComplete) {
finishScenario(context, module, scenario, start)
return
}
const publishResponse = await context.request('POST', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}/publish`, {
originMappings: mappings.originMappings,
overseasMappings: mappings.overseasMappings,
})
const published = context.apiSuccess(module, '确认仓库映射并立即生效', publishResponse,
`versionId=${versionId}, originMappings=${mappings.originMappings.length}, overseasMappings=${mappings.overseasMappings.length}`, blocker)
context.assert(module, '发布后价格版本为 ACTIVE 且无阻断', publishResponse,
published?.version?.status === 'ACTIVE'
&& Number(published?.version?.blockingErrorCount) === 0
&& Boolean(published?.version?.publishedAt)
&& published?.channels?.length === channels.length
&& published?.rates?.length === rates.length
&& !(published?.issues || []).some((issue) => issue.severity === 'BLOCKING'),
`status=${published?.version?.status || 'missing'}, blocking=${published?.version?.blockingErrorCount ?? 'n/a'}, publishedAt=${published?.version?.publishedAt || 'missing'}, channels=${published?.channels?.length ?? 'n/a'}, rates=${published?.rates?.length ?? 'n/a'}`, blocker)
const listAfterResponse = await context.request('GET', PRICE_BOOK_PATH)
const versionsAfter = context.apiSuccess(module, '发布后回查当前生效和历史版本', listAfterResponse, '', blocker)
const activeAfter = versionsAfter?.filter?.((version) => version.status === 'ACTIVE') || []
const oldStatusesCorrect = activeBefore.every((before) => {
if (String(before.id) === versionId) return true
const after = versionsAfter?.find?.((candidate) => String(candidate.id) === String(before.id))
return after?.status === 'SUPERSEDED' && Boolean(after.supersededAt)
})
context.assert(module, '始终只有一份当前生效版本', listAfterResponse,
activeAfter.length === 1 && String(activeAfter[0]?.id) === versionId,
`active=${activeAfter.map((item) => item.id).join(',') || 'none'}`, blocker)
context.assert(module, '上一生效版本已保留为历史版本', listAfterResponse,
oldStatusesCorrect,
`previous=${activeBefore.map((item) => item.id).join(',') || 'none'}, superseded=${oldStatusesCorrect}`, blocker)
const activeDetailResponse = await context.request('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}`)
const activeDetail = context.apiSuccess(module, '查询当前生效价格版本详情', activeDetailResponse, '', blocker)
context.assert(module, '当前生效版本预览数据完整', activeDetailResponse,
activeDetail?.version?.status === 'ACTIVE'
&& activeDetail?.channels?.length === channels.length
&& activeDetail?.rates?.length === rates.length,
`status=${activeDetail?.version?.status || 'missing'}, channels=${activeDetail?.channels?.length ?? 'n/a'}, rates=${activeDetail?.rates?.length ?? 'n/a'}`, blocker)
const repeatedPublishResponse = await context.request('POST', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}/publish`, {
originMappings: mappings.originMappings,
overseasMappings: mappings.overseasMappings,
})
const repeatRejected = repeatedPublishResponse.http === 200
&& String(repeatedPublishResponse.body?.code) !== '200'
context.addStep(module, '已生效版本禁止重复发布', repeatedPublishResponse, repeatRejected,
`HTTP=${repeatedPublishResponse.http}, code=${repeatedPublishResponse.body?.code ?? 'missing'}`, blocker)
const finalListResponse = await context.request('GET', PRICE_BOOK_PATH)
const finalVersions = context.apiSuccess(module, '重复发布被拒绝后回查版本状态', finalListResponse, '', blocker)
const finalActive = finalVersions?.filter?.((version) => version.status === 'ACTIVE') || []
context.assert(module, '重复发布未改变唯一生效版本', finalListResponse,
finalActive.length === 1 && String(finalActive[0]?.id) === versionId,
`active=${finalActive.map((item) => item.id).join(',') || 'none'}`, blocker)
finishScenario(context, module, scenario, start)
}
+148
View File
@@ -0,0 +1,148 @@
function id(value) { return value == null ? '' : String(value) }
export async function run(context) {
const module = 'system'
const suffix = context.marker.replace(/\D/g, '').slice(-12)
const permissionsResponse = await context.request('GET', '/api/platform/v1/iam/permissions')
const permissions = context.apiSuccess(module, '查询菜单权限资源', permissionsResponse)
context.assert(module, '权限资源返回数组', permissionsResponse, Array.isArray(permissions),
`count=${Array.isArray(permissions) ? permissions.length : 'n/a'}`)
const permissionPayload = {
type: 'BUTTON',
code: `platform:acceptance:${suffix}`,
name: `Admin验收权限-${suffix}`,
parentId: null,
routePath: null,
httpMethod: null,
sortNo: 9999,
}
const createPermissionResponse = await context.request('POST', '/api/platform/v1/iam/permissions', permissionPayload)
const permission = context.apiSuccess(module, '创建自定义权限资源', createPermissionResponse)
const permissionId = id(permission?.id)
context.assert(module, '权限创建字段正确', createPermissionResponse,
permissionId && permission?.permissionCode === permissionPayload.code && permission?.builtIn === false,
`permissionId=${permissionId || 'missing'}`)
if (!permissionId) throw new Error('权限资源创建失败,系统模块无法继续')
context.createdResources.systemPermission = { id: permissionId, code: permissionPayload.code }
const updatePermissionResponse = await context.request('PUT', `/api/platform/v1/iam/permissions/${permissionId}`, {
name: `${permissionPayload.name}-已编辑`, parentId: null, routePath: null, httpMethod: null, sortNo: 9998,
})
const updatedPermission = context.apiSuccess(module, '编辑自定义权限资源', updatePermissionResponse)
context.assert(module, '权限编辑正确回显', updatePermissionResponse,
updatedPermission?.permissionName === `${permissionPayload.name}-已编辑` && Number(updatedPermission?.sortNo) === 9998,
`permissionId=${permissionId}`)
for (const status of ['DISABLED', 'ACTIVE']) {
const response = await context.request('PUT', `/api/platform/v1/iam/permissions/${permissionId}/status`, { status })
const changed = context.apiSuccess(module, `${status === 'ACTIVE' ? '启用' : '停用'}自定义权限`, response)
context.assert(module, `权限状态回显 ${status}`, response, changed?.status === status, `status=${changed?.status ?? 'missing'}`)
}
const rolePayload = { roleCode: `ADMIN_ACCEPT_${suffix}`, roleName: `Admin验收角色-${suffix}`, description: context.marker }
const createRoleResponse = await context.request('POST', '/api/platform/v1/iam/roles', rolePayload)
const role = context.apiSuccess(module, '创建平台自定义角色', createRoleResponse)
const roleId = id(role?.id)
context.assert(module, '角色创建字段正确', createRoleResponse,
roleId && role?.roleCode === rolePayload.roleCode && role?.roleType === 'CUSTOM', `roleId=${roleId || 'missing'}`)
if (!roleId) throw new Error('角色创建失败,系统模块无法继续')
context.createdResources.systemRole = { id: roleId, code: rolePayload.roleCode, finalStatus: 'DISABLED', retainedBecauseAssigned: true }
const editRoleResponse = await context.request('PUT', `/api/platform/v1/iam/roles/${roleId}`, {
roleName: `${rolePayload.roleName}-已编辑`, description: `${context.marker}-EDITED`,
})
const editedRole = context.apiSuccess(module, '编辑平台自定义角色', editRoleResponse)
context.assert(module, '角色编辑正确回显', editRoleResponse,
editedRole?.roleName === `${rolePayload.roleName}-已编辑`, `roleId=${roleId}`)
const grantResponse = await context.request('PUT', '/api/platform/v1/iam/roles/permissions', { roleId, permissionIds: [permissionId] })
context.apiSuccess(module, '角色全量保存权限', grantResponse)
const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles')
const roles = context.apiSuccess(module, '回查平台角色列表', rolesResponse)
const listedRole = roles?.find?.((item) => id(item.id) === roleId)
context.assert(module, '角色权限 ID 正确回显', rolesResponse,
listedRole && listedRole.permissionIds?.map(id).includes(permissionId),
`permissionIds=${listedRole?.permissionIds?.map(id).join(',') || 'missing'}`)
for (const status of ['DISABLED', 'ACTIVE']) {
const response = await context.request('PUT', `/api/platform/v1/iam/roles/${roleId}/status`, { status })
const changed = context.apiSuccess(module, `${status === 'ACTIVE' ? '启用' : '停用'}自定义角色`, response)
context.assert(module, `角色状态回显 ${status}`, response, changed?.status === status, `status=${changed?.status ?? 'missing'}`)
}
const optionsResponse = await context.request('GET', '/api/platform/v1/iam/users/options')
const options = context.apiSuccess(module, '查询内部用户可选平台角色', optionsResponse)
context.assert(module, '用户选项包含平台与测试角色', optionsResponse,
options?.tenants?.some((item) => id(item.id) === '0') && options?.roles?.some((item) => id(item.id) === roleId),
`tenants=${options?.tenants?.length ?? 'n/a'}, roles=${options?.roles?.length ?? 'n/a'}`)
const phone = await context.findAvailablePhone(module)
const username = `admin_accept_${suffix}`
const initialPassword = `Accept${suffix}A1`
const resetPassword = `Reset${suffix}B2`
const userPayload = {
username, displayName: `Admin验收用户-${suffix}`, phone,
email: `${username}@example.com`, tenantId: '0', roleIds: [roleId],
password: initialPassword, status: 'ACTIVE',
}
const createUserResponse = await context.request('POST', '/api/platform/v1/iam/users', userPayload)
const user = context.apiSuccess(module, '创建平台内部用户', createUserResponse)
const membershipId = id(user?.membershipId)
context.assert(module, '内部用户账号、平台范围和角色正确', createUserResponse,
membershipId && user?.username === username && id(user?.tenantId) === '0' && user?.roleIds?.map(id).includes(roleId),
`membershipId=${membershipId || 'missing'}`)
if (!membershipId) throw new Error('平台内部用户创建失败,系统模块无法继续')
context.createdResources.systemUser = { membershipId, username, finalStatus: 'DISABLED' }
const pageResponse = await context.request('GET', `/api/platform/v1/iam/users?page=1&size=20&keyword=${username}`)
const page = context.apiSuccess(module, '按账号查询平台内部用户', pageResponse)
const listedUser = page?.records?.find((item) => id(item.membershipId) === membershipId)
const missingUserFields = listedUser ? requiredUserFields().filter((field) => !Object.hasOwn(listedUser, field)) : requiredUserFields()
context.assert(module, '用户列表包含新账号且字段完整', pageResponse,
listedUser && missingUserFields.length === 0
&& (!Object.hasOwn(listedUser, 'lastLoginAt') || typeof listedUser.lastLoginAt === 'string'),
`records=${page?.records?.length ?? 'n/a'}, missing=${missingUserFields.join(',') || 'none'}, lastLoginAt=${listedUser?.lastLoginAt ?? 'omitted'}`)
const updateUserPayload = {
displayName: `${userPayload.displayName}-已编辑`, phone,
email: `edited-${userPayload.email}`, tenantId: '0', roleIds: [roleId],
}
const updateUserResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}`, updateUserPayload)
const updatedUser = context.apiSuccess(module, '编辑平台内部用户', updateUserResponse)
context.assert(module, '用户编辑正确回显', updateUserResponse,
updatedUser?.displayName === updateUserPayload.displayName && updatedUser?.email === updateUserPayload.email,
`membershipId=${membershipId}`)
const loginResponse = await context.request('POST', '/api/auth/login', { username, password: initialPassword }, '')
const loginPassed = loginResponse.http === 200 && String(loginResponse.body?.code) === '200' && Boolean(loginResponse.body?.data?.accessToken)
context.addStep(module, '新建内部用户可使用初始密码登录', loginResponse, loginPassed, `username=${username}`)
const resetResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/password`, { newPassword: resetPassword })
context.apiSuccess(module, '重置平台内部用户密码', resetResponse)
const resetLoginResponse = await context.request('POST', '/api/auth/login', { username, password: resetPassword }, '')
const resetLoginPassed = resetLoginResponse.http === 200 && String(resetLoginResponse.body?.code) === '200' && Boolean(resetLoginResponse.body?.data?.accessToken)
context.addStep(module, '内部用户使用重置密码登录', resetLoginResponse, resetLoginPassed, `username=${username}`)
const disableUserResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/status`, { status: 'DISABLED' })
const disabledUser = context.apiSuccess(module, '停用平台内部用户', disableUserResponse)
context.assert(module, '内部用户停用状态正确', disableUserResponse, disabledUser?.status === 'DISABLED', `status=${disabledUser?.status ?? 'missing'}`)
const disabledLoginResponse = await context.request('POST', '/api/auth/login', { username, password: resetPassword }, '')
context.addStep(module, '停用用户禁止登录', disabledLoginResponse,
disabledLoginResponse.http === 200 && String(disabledLoginResponse.body?.code) !== '200',
`HTTP=${disabledLoginResponse.http}, code=${disabledLoginResponse.body?.code ?? 'missing'}`)
const clearPermissionResponse = await context.request('PUT', '/api/platform/v1/iam/roles/permissions', { roleId, permissionIds: [] })
context.apiSuccess(module, '清空测试角色权限', clearPermissionResponse)
const deleteRoleResponse = await context.request('DELETE', `/api/platform/v1/iam/roles/${roleId}`)
context.expectConflict(module, '已分配成员的角色禁止删除', deleteRoleResponse, 'system-role-protection')
const finalDisableRoleResponse = await context.request('PUT', `/api/platform/v1/iam/roles/${roleId}/status`, { status: 'DISABLED' })
const finalRole = context.apiSuccess(module, '清理时停用测试角色', finalDisableRoleResponse)
context.assert(module, '保留测试角色处于停用状态', finalDisableRoleResponse, finalRole?.status === 'DISABLED',
`status=${finalRole?.status ?? 'missing'}`)
const deletePermissionResponse = await context.request('DELETE', `/api/platform/v1/iam/permissions/${permissionId}`)
context.apiSuccess(module, '删除临时权限资源', deletePermissionResponse)
}
function requiredUserFields() {
return ['membershipId', 'userId', 'authUserId', 'username', 'displayName', 'phone', 'email',
'tenantId', 'tenantName', 'memberType', 'status', 'roleIds', 'roleNames', 'protectedAccount']
}
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const SCENARIO = 'mojia-measurement'
const DEFAULT_IMAGE = resolve(
dirname(fileURLToPath(import.meta.url)), '..', '..', 'apps', 'pda-uniapp', 'static', 'c7.png')
function success(response) {
return response?.http === 200 && String(response?.body?.code) === '200'
}
function data(response) {
return response?.body?.data ?? null
}
function record(run, name, response, passed, evidence = '') {
return run.addStep(SCENARIO, name, response, passed, evidence)
}
function responseOf(http, body, raw = null) {
return {
method: 'POST',
path: null,
http,
body,
raw,
requestId: body?.requestId ?? null,
traceId: body?.traceId ?? null,
}
}
async function multipartRequest(run, path, fields, imagePath) {
const form = new FormData()
form.set('data', JSON.stringify(fields))
form.set('image', new Blob([await readFile(imagePath)], { type: 'image/png' }), 'mojia-e2e.png')
try {
const response = await fetch(`${run.options.gateway}${path}`, {
method: 'POST',
headers: { Accept: 'application/json', Authorization: `Bearer ${run.token}` },
body: form,
signal: AbortSignal.timeout(30_000),
})
const text = await response.text()
let body = null
try { body = text ? JSON.parse(text) : null } catch { /* Keep raw response for the report. */ }
return {
method: 'POST', path, http: response.status, body,
raw: body === null ? text.slice(0, 500) : null,
requestId: body?.requestId ?? response.headers.get('x-request-id'),
traceId: body?.traceId ?? response.headers.get('x-trace-id'),
}
} catch (error) {
return responseOf(0, null, error instanceof Error ? error.message : String(error))
}
}
function requireData(run, name, response, evidence = '') {
record(run, name, response, success(response) && data(response) !== null, evidence)
if (!success(response) || data(response) === null) {
throw new Error(`${name} failed: code=${response?.body?.code}, message=${response?.body?.message}`)
}
return data(response)
}
/**
* Run the internal platform simulation for the MoJia carton measurement flow.
* The final external MoJia key/signature contract is intentionally not defined here.
*
* @param {object} run Existing AcceptanceRun instance from admin-test-env-acceptance.mjs.
* @param {object} [options]
* @param {string|number} [options.tenantId] Customer scope for selecting the test inbound.
* @param {string|number} [options.inboundId] Explicit WAIT_MEASURE inbound, preferred for CI.
* @param {string} [options.imagePath] PNG/JPG evidence image path.
*/
export async function runMojiaMeasurementScenario(run, options = {}) {
const tenantId = options.tenantId ?? process.env.MOJIA_TEST_TENANT_ID
const imagePath = options.imagePath ?? process.env.MOJIA_TEST_IMAGE ?? DEFAULT_IMAGE
let inboundId = options.inboundId ?? process.env.MOJIA_TEST_INBOUND_ID
if (!tenantId && !inboundId) {
const response = responseOf(0, { code: 'DEPLOYMENT_BLOCKED', message: '缺少墨家测试 tenantId 或 inboundId' })
record(run, '墨家测量测试数据配置', response, false, '请传入 MOJIA_TEST_TENANT_ID 或 MOJIA_TEST_INBOUND_ID')
return false
}
try {
if (!inboundId) {
const response = await run.request(
'GET', '/api/platform/inbounds?processStatus=WAIT_MEASURE&page=1&size=200')
const rows = requireData(run, '查找墨家 WAIT_MEASURE 入库单', response)
const candidate = rows.records?.find((row) => String(row.tenantId) === String(tenantId)
&& row.totalPackageCount == null)
if (!candidate) {
record(run, '墨家测量测试数据配置', response, false,
`tenantId=${tenantId} 下没有未冻结总箱数的 WAIT_MEASURE 入库单`)
return false
}
inboundId = String(candidate.id)
}
const detailPath = `/api/platform/inbounds/${inboundId}?tenantId=${tenantId}`
const detailResponse = await run.request('GET', detailPath)
const detail = requireData(run, '查询墨家测试入库单', detailResponse, `inboundId=${inboundId}`)
const summary = detail.summary
if (summary.processStatus !== 'WAIT_MEASURE' || summary.totalPackageCount != null) {
record(run, '墨家测试入库单状态校验', detailResponse, false,
`processStatus=${summary.processStatus}, totalPackageCount=${summary.totalPackageCount}`)
return false
}
const resolvedTenantId = String(summary.tenantId ?? tenantId)
const printPath = `/api/platform/inbounds/${inboundId}/carton-label-pdf?tenantId=${resolvedTenantId}`
const printResponse = await run.requestBinary('POST', printPath)
record(run, '墨家测试生成稳定箱唛 PDF', printResponse,
printResponse.http === 200 && printResponse.bytes?.length > 0
&& printResponse.contentType?.toLowerCase().includes('application/pdf'),
`inboundId=${inboundId}, bytes=${printResponse.bytes?.length ?? 0}`)
const afterPrintResponse = await run.request('GET', detailPath)
const afterPrint = requireData(run, '回查墨家稳定箱号总数', afterPrintResponse)
const printedTotal = Number(afterPrint.summary.totalPackageCount)
if (!Number.isInteger(printedTotal) || printedTotal < 1) {
throw new Error('打印后没有冻结原始总箱数')
}
const boxCode = `${afterPrint.summary.inboundCode}#${printedTotal}-1`
const scanPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/carton-scans?tenantId=${resolvedTenantId}`
const firstFields = {
boxCode, length: 60.1, width: 40.2, height: 30.3, weight: 12.5,
machineNo: 'MJ-ADMIN-E2E-01', measuredAt: '2026-08-08T10:00:00',
}
const firstResponse = await multipartRequest(run, scanPath, firstFields, imagePath)
const first = requireData(run, '墨家单箱首次测量上传', firstResponse, `boxCode=${boxCode}`)
const firstPassed = first.created === true && first.confirmedPackageCount === 1
&& String(first.boxCode) === boxCode
record(run, '墨家单箱首次记录语义', firstResponse, firstPassed,
`itemId=${first.itemId}, measurementId=${first.measurementId}`)
if (!firstPassed) throw new Error('首次扫描没有创建单箱记录或默认箱数不是 1')
const countPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/items/${first.itemId}/confirmed-package-count?tenantId=${resolvedTenantId}`
const countResponse = await run.request('PUT', countPath, {
measurementVersion: first.measurementVersion,
confirmedPackageCount: printedTotal,
})
const counted = requireData(run, '人工确认墨家单箱代表箱数', countResponse,
`confirmedPackageCount=${printedTotal}`)
if (counted.confirmedPackageCount !== printedTotal) {
throw new Error('人工确认箱数回显不一致')
}
const rescanResponse = await multipartRequest(run, scanPath, {
boxCode, length: 61.1, width: 41.2, height: 31.3, weight: 13.5,
machineNo: 'MJ-ADMIN-E2E-02', measuredAt: '2026-08-08T10:01:00',
}, imagePath)
const rescan = requireData(run, '墨家同箱复测覆盖', rescanResponse)
const rescanPassed = rescan.created === false
&& String(rescan.itemId) === String(first.itemId)
&& rescan.confirmedPackageCount === printedTotal
&& Number(rescan.lengthCm) === 61.1
record(run, '墨家同箱复测保留人工箱数', rescanResponse, rescanPassed,
`itemId=${rescan.itemId}, confirmedPackageCount=${rescan.confirmedPackageCount}`)
if (!rescanPassed) throw new Error('同箱复测没有覆盖设备值或覆盖人工箱数')
const submitPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/submit?tenantId=${resolvedTenantId}`
const submitResponse = await run.request('POST', submitPath, {
measurementId: rescan.measurementId,
version: rescan.measurementVersion,
})
const submitted = requireData(run, '提交墨家测量审核', submitResponse)
const submittedPassed = submitted.processStatus === 'MEASURE_PENDING'
&& submitted.measureReviewStatus === 'PENDING'
record(run, '墨家测量进入待审核', submitResponse, submittedPassed,
`processStatus=${submitted.processStatus}, reviewStatus=${submitted.measureReviewStatus}`)
if (!submittedPassed) throw new Error('墨家测量提交后状态不正确')
const blockedResponse = await multipartRequest(run, scanPath, {
boxCode, length: 99.1, width: 99.2, height: 99.3, weight: 99.4,
machineNo: 'MJ-ADMIN-E2E-BLOCKED', measuredAt: '2026-08-08T10:02:00',
}, imagePath)
const blockedPassed = blockedResponse.body?.code === 'A0409'
&& blockedResponse.body?.message?.includes('测量已提交')
record(run, '提交后拒绝墨家设备覆盖', blockedResponse, blockedPassed,
`code=${blockedResponse.body?.code}, message=${blockedResponse.body?.message ?? ''}`)
if (!blockedPassed) throw new Error('提交后没有拒绝墨家设备覆盖')
run.context ??= {}
run.context.mojiaMeasurement = {
tenantId: resolvedTenantId, inboundId: String(inboundId), boxCode,
measurementId: String(rescan.measurementId), itemId: String(rescan.itemId),
}
return true
} catch (error) {
const response = responseOf(0, null, error instanceof Error ? error.message : String(error))
record(run, '墨家测量场景执行', response, false, response.raw)
return false
}
}