Files
baiyue-firstleg/scripts/admin-test-env-modules/return-boundary-scenarios.mjs
T

281 lines
20 KiB
JavaScript

import { run as createMerchant } from './merchant-qualification-fixture.mjs'
import { createPicker, disablePicker } from './phase4-overseas-pda.mjs'
const MODULE = 'phase4-overseas-return-receipt'
const OPTIONS = { scenario: 'return-isolation-concurrency-boundaries', deploymentBlocker: true }
const ORDERS = '/api/merchant/v1/last-leg/return-orders'
const INVENTORY = '/api/merchant/v1/last-leg/return-inventory'
const RECEIPTS = '/api/pda/v1/last-leg/return-receipts'
const MERGE = '/api/pda/v1/last-leg/return-inventory/cartons/merge'
const rows = data => Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : []
const ok = response => response?.http === 200 && String(response?.body?.code) === '200'
export function rejected(response, statuses) {
const code = String(response?.body?.code || '')
return Boolean(response?.requestId && response?.traceId)
&& statuses.some(status => response?.http === status
|| (response?.http === 200 && [String(status), `A0${status}`].includes(code)))
}
export function classifyRace(responses) {
return { successes: responses.filter(ok).length,
conflicts: responses.filter(response => rejected(response, [409])).length,
unexpected: responses.filter(response => !ok(response) && !rejected(response, [409])).length }
}
export function stableState(value) {
if (Array.isArray(value)) return value.map(stableState).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort()
.filter(key => !['createdAt', 'updatedAt', 'receivedAt', 'generatedAt'].includes(key))
.map(key => [key, stableState(value[key])]))
return value
}
export async function verifyNoMutation({ snapshot, action, check, name, expected }) {
const before = await snapshot()
const response = await action()
check(name, response, expected(response))
const after = await snapshot()
check(`${name}后立即核对订单批次箱及SKU均不变`, response,
JSON.stringify(stableState(before)) === JSON.stringify(stableState(after)))
return response
}
export async function runReturnBoundaries(context, { merchantToken, warehouse, picker, access }) {
let sequence = 0
const prefix = `RBX-${context.marker.replace(/[^A-Z0-9]/gi, '').slice(-18)}`
const key = name => `${prefix}-${name}-${++sequence}`
const extraPickers = []
let failures = 0
const resources = context.createdResources.returnBoundaryScenarios = { returnNos: [], cartonMarks: [], cases: [], concurrencyRounds: 3 }
function check(name, response, passed, evidence = '') {
if (!passed) failures += 1
context.assert(MODULE, name, response, Boolean(passed), evidence, OPTIONS)
}
const request = (token, method, path, body) => context.requestOverseas(method, path, body, {}, token)
async function good(token, method, name, path, body) {
const response = await request(token, method, path, body)
check(name, response, ok(response))
if (!ok(response)) throw new Error(`${name}: HTTP=${response?.http}, code=${response?.body?.code}`)
return response.body.data
}
const url = (path, values) => `${path}?${new URLSearchParams(values)}`
async function newOrder(name, sku, quantity, token = merchantToken, warehouseId = warehouse.id) {
const data = await good(token, 'POST', `准备${name}退货单`, ORDERS, {
overseasWarehouseId: warehouseId, warehouseContactName: '退货边界验收', warehouseContactPhone: '00000000000',
customerReferenceNo: `${prefix}-${name}`, needRelabel: false, idempotencyKey: key('ORDER'),
items: [{ skuCode: sku, productNameZh: '边界验收商品', expectedQuantity: quantity }],
})
resources.returnNos.push(data.order.returnNo)
return { returnNo: data.order.returnNo, sku, quantity, token, warehouseId }
}
function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) {
return { returnNo: order.returnNo, startedBy: 'RETURN_NO', items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey }
}
async function receive(order, operator = picker) {
const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order))
order.cartonMark = data.cartonMark
order.batch = data.receiptBatchNo
resources.cartonMarks.push(data.cartonMark)
return data
}
function mergeBody(orders, sku, quantity, idempotencyKey = key('MERGE')) {
return { returnNos: orders.map(order => order.returnNo), items: [{ skuCode: sku, quantity }],
length: 30, width: 20, height: 10, weight: 1.2, idempotencyKey }
}
async function snapshot(orders) {
const result = []
for (const order of orders) {
const detail = await good(order.token, 'GET', '读取订单数量与签收批次快照', `${ORDERS}/${order.returnNo}`)
const cartons = await good(order.token, 'GET', '读取全部状态箱快照', url(`${INVENTORY}/cartons`, { returnNo: order.returnNo, page: 1, size: 200 }))
const items = []
for (const carton of rows(cartons)) items.push(await good(order.token, 'GET', '读取箱内SKU快照', `${INVENTORY}/cartons/${carton.cartonMark}`))
const summary = await good(order.token, 'GET', '读取SKU数量及所在箱数快照', url(`${INVENTORY}/summary`, { skuCode: order.sku, overseasWarehouseId: order.warehouseId, page: 1, size: 200 }))
result.push({ detail, cartons, items, summary })
}
return result
}
async function unchanged(name, orders, action, statuses) {
return verifyNoMutation({ snapshot: () => snapshot(orders), action, check, name,
expected: response => rejected(response, statuses) })
}
async function scenario(name, fn) {
const before = failures
try { await fn(); resources.cases.push({ name, executed: true, passed: failures === before }) }
catch (error) { check(`${name}场景未完成`, null, false, error.message); resources.cases.push({ name, executed: false, passed: false }) }
}
function recordRace(name, responses) {
for (const [index, response] of responses.entries()) {
check(`${name}请求${index + 1}仅成功或业务冲突`, response, ok(response) || rejected(response, [409]))
}
return classifyRace(responses)
}
try {
// The second customer owns its own context and never replaces primary credentials.
const secondary = Object.create(context)
secondary.marker = `${context.marker}-B`
secondary.options = { ...context.options, customerName: '' }
secondary.context = { ...context.context }
secondary.createdResources = {}
await createMerchant(secondary, { publishEnvironment: false })
const fixtureB = secondary.context.merchantQualificationFixture
resources.secondaryCustomer = secondary.createdResources.merchantQualificationFixture
const loginB = await context.request('POST', '/api/auth/login', { loginId: fixtureB.username, password: fixtureB.password }, '')
check('第二隔离客户登录取得海外令牌', loginB, ok(loginB) && Boolean(loginB.body?.data?.overseasAccessToken))
if (!ok(loginB) || !loginB.body?.data?.overseasAccessToken) return
const tokenB = loginB.body.data.overseasAccessToken
const warehouseResponse = await context.request('GET', '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
const otherWarehouse = rows(warehouseResponse?.body?.data).find(row => String(row.id) !== String(warehouse.id))
check('跨仓场景具备第二个真实有效海外仓', warehouseResponse, ok(warehouseResponse) && Boolean(otherWarehouse))
const sameWarehousePicker = await createPicker(context, 93, warehouse.id, access)
if (sameWarehousePicker?.membershipId) extraPickers.push([sameWarehousePicker, 93])
check('并发场景具备第二个同仓操作员', null, Boolean(sameWarehousePicker?.token))
await scenario('跨客户隔离', async () => {
const a = await newOrder('客户A', `${prefix}-ISO-A`, 1)
const b = await newOrder('客户B', `${prefix}-ISO-B`, 1, tokenB)
await receive(a); await receive(b)
for (const [name, path] of [
['退货单详情', `${ORDERS}/${b.returnNo}`],
['箱明细', `${INVENTORY}/cartons/${b.cartonMark}`],
['箱标签', `${INVENTORY}/cartons/${b.cartonMark}/label`],
]) await unchanged(`客户A禁止读取客户B${name}`, [a, b], () => request(merchantToken, 'GET', path), [403, 404])
for (const [name, path, filter] of [
['退货单列表', ORDERS, { returnNo: b.returnNo }],
['箱列表', `${INVENTORY}/cartons`, { returnNo: b.returnNo }],
['SKU汇总', `${INVENTORY}/summary`, { skuCode: b.sku }],
]) {
const data = await good(merchantToken, 'GET', `客户A查询客户B${name}`, url(path, { ...filter, customerId: fixtureB.tenantId, page: 1, size: 20 }))
check(`伪造客户筛选不能泄露${name}`, null, Number(data.total) === 0 && rows(data).length === 0)
}
await unchanged('不同客户完成单禁止合箱', [a, b], () => request(picker.token, 'POST', MERGE, mergeBody([a, b], a.sku, 1)), [409])
})
await scenario('跨仓隔离', async () => {
if (!otherWarehouse) throw new Error('缺少第二仓,不能跳过跨仓验证')
const foreignPicker = await createPicker(context, 94, String(otherWarehouse.id), access)
if (foreignPicker?.membershipId) extraPickers.push([foreignPicker, 94])
if (!foreignPicker?.token) throw new Error('第二仓操作员未就绪')
const order = await newOrder('异仓', `${prefix}-WAREHOUSE`, 1, merchantToken, String(otherWarehouse.id))
await unchanged('非绑定仓禁止开始签收', [order], () => request(picker.token, 'POST', `${RECEIPTS}/start`, { returnNo: order.returnNo }), [404])
await unchanged('非绑定仓禁止确认签收', [order], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order)), [404])
const candidates = await good(picker.token, 'POST', '本仓查询异仓SKU候选', `${RECEIPTS}/sku-candidates`, { skuCode: order.sku })
check('SKU候选不串仓', null, Array.isArray(candidates.candidates) && candidates.candidates.length === 0)
await receive(order, foreignPicker)
await unchanged('非绑定仓禁止读取箱标签', [order], () => request(picker.token, 'GET', `/api/pda/v1/last-leg/return-inventory/cartons/${order.cartonMark}/label`), [404])
await unchanged('非绑定仓禁止合箱', [order], () => request(picker.token, 'POST', MERGE, mergeBody([order], order.sku, 1)), [404])
})
await scenario('并发签收', async () => {
if (!sameWarehousePicker?.token) throw new Error('第二同仓操作员未就绪')
for (let round = 1; round <= resources.concurrencyRounds; round += 1) {
const order = await newOrder(`并发签收${round}`, `${prefix}-RACE-RECEIPT-${round}`, 1)
const responses = await Promise.all([picker, sameWarehousePicker].map(operator => request(operator.token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order))))
const outcome = recordRace('并发签收', responses)
check('两个操作员抢收最后一件仅一次成功', responses[0], outcome.successes === 1 && outcome.conflicts === 1 && outcome.unexpected === 0, JSON.stringify(outcome))
const [state] = await snapshot([order])
check('并发签收仅一批一箱一件', null, state.detail.receipts.length === 1 && Number(state.detail.order.receivedQuantity) === 1
&& Number(state.cartons.total) === 1 && Number(rows(state.summary)[0]?.availableQuantity) === 1)
const replayOrder = await newOrder(`并发同键${round}`, `${prefix}-SAME-KEY-${round}`, 1)
const body = receiptBody(replayOrder)
const sameKeyResponses = await Promise.all([1, 2].map(() => request(picker.token, 'POST', `${RECEIPTS}/confirm`, body)))
for (const [index, response] of sameKeyResponses.entries()) check(`同键并发请求${index + 1}成功`, response, ok(response))
check('同键并发签收返回同一成功结果', sameKeyResponses[0], sameKeyResponses.every(ok)
&& sameKeyResponses[0].body.data.receiptNo === sameKeyResponses[1].body.data.receiptNo)
const beforeRetry = await snapshot([replayOrder])
check('同键并发仅生成一批一箱一件', null, beforeRetry[0].detail.receipts.length === 1
&& Number(beforeRetry[0].detail.order.receivedQuantity) === 1 && Number(beforeRetry[0].cartons.total) === 1
&& rows(beforeRetry[0].summary).some(row => row.skuCode === replayOrder.sku && Number(row.availableQuantity) === 1))
const retry = await request(picker.token, 'POST', `${RECEIPTS}/confirm`, body)
const accepted = sameKeyResponses.find(ok)?.body.data
check('模拟成功响应丢失后原键重试复用原签收', retry, ok(retry) && accepted
&& ['receiptNo', 'receiptBatchNo', 'cartonMark'].every(field => retry.body.data[field] === accepted[field]))
check('重试后签收和库存不重复', retry, JSON.stringify(stableState(beforeRetry)) === JSON.stringify(stableState(await snapshot([replayOrder]))))
}
})
await scenario('并发合箱', async () => {
if (!sameWarehousePicker?.token) throw new Error('第二同仓操作员未就绪')
for (let round = 1; round <= resources.concurrencyRounds; round += 1) {
const order = await newOrder(`并发合箱${round}`, `${prefix}-RACE-MERGE-${round}`, 1)
await receive(order)
const responses = await Promise.all([picker, sameWarehousePicker].map(operator => request(operator.token, 'POST', MERGE, mergeBody([order], order.sku, 1))))
const outcome = recordRace('并发合箱', responses)
check('两个操作员抢合最后一件仅一次成功', responses[0], outcome.successes === 1 && outcome.conflicts === 1 && outcome.unexpected === 0, JSON.stringify(outcome))
const state = await snapshot([order])
check('并发合箱无负数且SKU数量箱数均为一', null, rows(state[0].summary).some(row => row.skuCode === order.sku && Number(row.availableQuantity) === 1 && Number(row.cartonQuantity) === 1)
&& Number(state[0].cartons.total) === 2
&& rows(state[0].cartons).filter(row => row.status === 'AVAILABLE' && Number(row.totalQuantity) === 1).length === 1
&& rows(state[0].cartons).filter(row => row.status === 'MERGED' && Number(row.totalQuantity) === 0).length === 1)
const partial = await newOrder(`并发部分合箱${round}`, `${prefix}-RACE-PARTIAL-${round}`, 3)
await receive(partial)
const partialResponses = await Promise.all([picker, sameWarehousePicker].map(operator => request(operator.token, 'POST', MERGE, mergeBody([partial], partial.sku, 2))))
const partialOutcome = recordRace('并发部分合箱', partialResponses)
check('三件库存同时各取两件仅一次成功', partialResponses[0], partialOutcome.successes === 1
&& partialOutcome.conflicts === 1 && partialOutcome.unexpected === 0, JSON.stringify(partialOutcome))
const [partialState] = await snapshot([partial])
check('并发部分合箱保持原箱一件新箱两件总量三件', null, Number(partialState.cartons.total) === 2
&& rows(partialState.cartons).every(row => row.status === 'AVAILABLE')
&& rows(partialState.cartons).some(row => row.cartonMark === partial.cartonMark && Number(row.totalQuantity) === 1)
&& rows(partialState.cartons).some(row => row.cartonMark !== partial.cartonMark && Number(row.totalQuantity) === 2)
&& rows(partialState.summary).some(row => row.skuCode === partial.sku && Number(row.availableQuantity) === 3 && Number(row.cartonQuantity) === 2))
}
})
await scenario('多单全量合箱与来源追溯', async () => {
const sku = `${prefix}-MULTI`
const a = await newOrder('多单A', sku, 2)
const b = await newOrder('多单B', sku, 3)
const unused = await newOrder('未取货来源', `${prefix}-UNUSED`, 1)
await receive(a); await receive(b); await receive(unused)
const result = await good(picker.token, 'POST', '同客户多单全量合箱', MERGE, mergeBody([a, b, unused], sku, 5))
resources.cartonMarks.push(result.cartonMark)
const target = await good(merchantToken, 'GET', '回查多单目标箱', `${INVENTORY}/cartons/${result.cartonMark}`)
check('多单同SKU合并为一行五件', null, Number(target.totalQuantity) === 5 && target.items.length === 1 && Number(target.items[0].quantity) === 5)
check('来源单和批次仅含实际消耗且去重', null,
JSON.stringify(String(target.sourceReturnNo).split(',').sort()) === JSON.stringify([a.returnNo, b.returnNo].sort())
&& JSON.stringify(String(target.sourceBatchNo).split(',').sort()) === JSON.stringify([a.batch, b.batch].sort()))
for (const source of [a, b]) {
const carton = await good(merchantToken, 'GET', '回查已耗尽来源箱', `${INVENTORY}/cartons/${source.cartonMark}`)
check('耗尽来源箱为MERGED且件数SKU数清零', null, carton.status === 'MERGED' && Number(carton.totalQuantity) === 0 && Number(carton.skuTypes) === 0 && carton.items.length === 0)
}
const untouched = await good(merchantToken, 'GET', '回查未取货来源箱', `${INVENTORY}/cartons/${unused.cartonMark}`)
check('选中但未取货来源保持可用一件', null, untouched.status === 'AVAILABLE' && Number(untouched.totalQuantity) === 1)
const summary = await good(merchantToken, 'GET', '回查全量合箱SKU汇总', url(`${INVENTORY}/summary`, { skuCode: sku, page: 1, size: 20 }))
check('全量合箱后仍五件但仅一个可用箱', null, rows(summary).some(row => row.skuCode === sku && Number(row.availableQuantity) === 5 && Number(row.cartonQuantity) === 1))
})
await scenario('多候选与输入边界', async () => {
const sku = `${prefix}-CANDIDATE`
const a = await newOrder('候选A', sku, 1)
const b = await newOrder('候选B', sku, 2)
const found = await good(picker.token, 'POST', '同SKU查询两张待签收单', `${RECEIPTS}/sku-candidates`, { skuCode: sku })
check('同SKU两候选各自保留预报数量', null, found.candidates.length === 2
&& found.candidates.some(row => row.returnNo === a.returnNo && Number(row.skuExpectedQuantity) === 1 && Number(row.skuReceivedQuantity) === 0)
&& found.candidates.some(row => row.returnNo === b.returnNo && Number(row.skuExpectedQuantity) === 2 && Number(row.skuReceivedQuantity) === 0))
await receive(a)
const remaining = await good(picker.token, 'POST', '一单完成后回查同SKU候选', `${RECEIPTS}/sku-candidates`, { skuCode: sku })
check('完成单移除而未收单数量不变', null, remaining.candidates.length === 1
&& remaining.candidates[0].returnNo === b.returnNo && Number(remaining.candidates[0].skuExpectedQuantity) === 2
&& Number(remaining.candidates[0].skuReceivedQuantity) === 0)
for (const [name, patch] of [
['已存在箱号', { cartonMark: a.cartonMark }],
['缺少幂等键', { idempotencyKey: '' }],
['超长快递单号', { trackingNo: 'X'.repeat(129) }],
]) await unchanged(`拒绝${name}`, [a, b], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, { ...receiptBody(b, 1), ...patch }), name === '已存在箱号' ? [409] : [400, 422])
await receive(b)
await unchanged('拒绝合箱重复SKU', [a, b], () => request(picker.token, 'POST', MERGE,
{ ...mergeBody([a, b], sku, 1), items: [{ skuCode: sku, quantity: 1 }, { skuCode: sku, quantity: 1 }] }), [400, 422])
await unchanged('拒绝小数合箱数量', [a, b], () => request(picker.token, 'POST', MERGE,
mergeBody([a, b], sku, 1.5)), [400, 422])
// Isolate fractional receipt from later fixtures: a server defect must not
// consume another case's pending quantity and hide its verification.
const fractional = await newOrder('小数实收', `${prefix}-FRACTIONAL`, 2)
await unchanged('拒绝小数实收', [fractional], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`,
receiptBody(fractional, 1.5)), [400, 422])
})
} finally {
for (const [operator, index] of extraPickers.reverse()) {
try { await disablePicker(context, operator, index) }
catch (error) { check(`停用临时PDA${index}失败`, null, false, error.message) }
}
}
}