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 PLATFORM_INVENTORY = '/api/platform/v1/last-leg/return-inventory' 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 function exactReceiptLedger(data, expected) { const project = row => ({ customerId: String(row.customerId), overseasWarehouseId: String(row.overseasWarehouseId), skuCode: row.skuCode, movementType: row.movementType, referenceNo: row.referenceNo, totalDelta: Number(row.totalDelta), availableDelta: Number(row.availableDelta) }) return Number(data?.total) === expected.length && rows(data).length === expected.length && JSON.stringify(stableState(rows(data).map(project))) === JSON.stringify(stableState(expected.map(project))) } export function palletReceiptBody(returnNo, palletCode, skuCode, receivedQuantity, idempotencyKey, startedBy) { return { returnNo, palletCode, unitType: 'PALLET', unitCode: palletCode, startedBy, length: 120, width: 100, height: 80, weight: 10, items: [{ skuCode, receivedQuantity, boxCount: 1 }], idempotencyKey, } } 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 foreignPicker const customerByToken = new Map([[merchantToken, String(context.context.merchantQualificationFixture.tenantId)]]) 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) } async function request(token, method, path, body) { let response = await context.request(method, path, body, token, {}) for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 250 * attempt)) response = await context.request(method, path, body, token, {}) } return response } 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, items = [{ skuCode: sku, productNameZh: '边界验收商品', expectedQuantity: quantity }]) { const data = await good(token, 'POST', `准备${name}退货单`, ORDERS, { overseasWarehouseId: warehouseId, warehouseContactName: '退货边界验收', warehouseContactPhone: '00000000000', customerReferenceNo: `${prefix}-${name}`, needRelabel: false, idempotencyKey: key('ORDER'), items, }) resources.returnNos.push(data.order.returnNo) check('新退货单使用日流水且尾程主单沿用来源业务号', null, /^TH[0-9]{12}$/.test(data.order.returnNo) && data.order.lastlegOrderNo === data.order.returnNo) const customerId = customerByToken.get(token) if (!customerId || data.order.status !== 'PENDING_REVIEW') throw new Error('边界退货客户或审核初态不符') const reviewed = await context.request('POST', `/api/platform/v1/last-leg/return-orders/${data.order.returnNo}/review`, { decision: 'APPROVE', remark: context.marker }, undefined, { 'X-Customer-Id': customerId }) check('边界退货单审核通过进入待签收', reviewed, ok(reviewed) && reviewed.body.data?.order?.status === 'PENDING_RECEIPT') if (!ok(reviewed) || reviewed.body.data?.order?.status !== 'PENDING_RECEIPT') throw new Error('边界退货审核失败') const operator = String(warehouseId) === String(warehouse.id) ? picker : foreignPicker if (!operator?.token) throw new Error('缺少本仓签收操作员') const form = new FormData() form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-boundary.png') const photo = await context.requestMultipart('POST', `${RECEIPTS}/${data.order.returnNo}/photos/files`, form, operator.token) check('边界签收准备合成打托照片', photo, ok(photo) && Boolean(photo.body.data?.fileId)) if (!ok(photo) || !photo.body.data?.fileId) throw new Error('边界照片上传失败') return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId, unitCode: `PKG-${data.order.returnNo}` } } function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) { // 合成包裹明确原包装选择与实测输入,使边界场景进入待验证的库存/并发规则。 return { returnNo: order.returnNo, startedBy: 'RETURN_NO', unitType: 'PARCEL', unitCode: order.unitCode, originalPackaging: true, length: 30, width: 20, height: 10, weight: 2, items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey } } async function receive(order, operator = picker) { const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order)) check('签收单批次采用独立完整编号且绑定本轮包裹', null, /^RR[0-9]{12}$/.test(data.receiptNo) && /^RB[0-9]{12}$/.test(data.receiptBatchNo) && data.unitType === 'PARCEL' && data.unitCode === order.unitCode && !data.palletCode) 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/${encodeURIComponent(carton.cartonMark)}`)) const summary = await good(order.token, 'GET', '读取SKU数量及所在箱数快照', url(`${INVENTORY}/summary`, { skuCode: order.sku, overseasWarehouseId: order.warehouseId, page: 1, size: 200 })) const additionalSummaries = [] for (const sku of order.skus.filter(sku => sku !== order.sku)) additionalSummaries.push(await good(order.token, 'GET', '读取其他SKU库存快照', url(`${INVENTORY}/summary`, { skuCode: sku, overseasWarehouseId: order.warehouseId, page: 1, size: 200 }))) result.push({ detail, cartons, items, summary, additionalSummaries }) } 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?.accessToken)) if (!ok(loginB) || !loginB.body?.data?.accessToken) return const tokenB = loginB.body.data.accessToken customerByToken.set(tokenB, String(fixtureB.tenantId)) 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/${encodeURIComponent(b.cartonMark)}`], ['箱标签', `${INVENTORY}/cartons/${encodeURIComponent(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('缺少第二仓,不能跳过跨仓验证') 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/${encodeURIComponent(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', 'unitCode', '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]) // PARCEL 不计纸箱数;剩余包裹和新 BOX 是两个库存单元,但只有一个纸箱。 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) === 1)) } }) 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/${encodeURIComponent(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/${encodeURIComponent(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/${encodeURIComponent(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 skuA = `${prefix}-ATOMIC-A`, skuB = `${prefix}-ATOMIC-B` const order = await newOrder('混合明细', skuA, 2, merchantToken, warehouse.id, [skuA, skuB].map(skuCode => ({ skuCode, expectedQuantity: 2 }))) const makeReceipt = (secondSku, quantity) => ({ ...receiptBody(order, 1), items: [ { skuCode: skuA, receivedQuantity: 1 }, { skuCode: secondSku, receivedQuantity: quantity }] }) for (const [name, secondSku, quantity] of [['后项超收', skuB, 3], ['后项未预报', `${prefix}-ATOMIC-UNKNOWN`, 1]]) { await unchanged(`混合签收${name}必须整笔失败`, [order], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, makeReceipt(secondSku, quantity)), [409]) } await good(picker.token, 'POST', '两个合法SKU同包裹签收生成独立散件库存', `${RECEIPTS}/confirm`, { ...receiptBody(order), items: [skuA, skuB].map(skuCode => ({ skuCode, receivedQuantity: 2 })) }) for (const [name, secondSku, quantity] of [['后项超库存', skuB, 3], ['后项不存在', `${prefix}-ATOMIC-UNKNOWN`, 1]]) { await unchanged(`混合合箱${name}必须整笔回滚`, [order], () => request(picker.token, 'POST', MERGE, { ...mergeBody([order], skuA, 1), items: [{ skuCode: skuA, quantity: 1 }, { skuCode: secondSku, quantity }] }), [409]) } const [state] = await snapshot([order]) check('混合失败后两SKU各两件且保留同一原包裹', null, Number(state.cartons.total) === 1 && [state.summary, ...state.additionalSummaries].every(page => rows(page).length === 1 && Number(rows(page)[0].availableQuantity) === 2)) }) await scenario('同键并发合箱', async () => { for (let round = 1; round <= resources.concurrencyRounds; round += 1) { const order = await newOrder(`同键合箱${round}`, `${prefix}-MERGE-KEY-${round}`, 2) await receive(order) const body = mergeBody([order], order.sku, 1) const responses = await Promise.all([1, 2].map(() => request(picker.token, 'POST', MERGE, body))) for (const [index, response] of responses.entries()) check(`同键并发合箱请求${index + 1}成功`, response, ok(response)) check('同键并发合箱复用同一目标箱', responses[0], responses.every(ok) && /^BX-[0-9A-Z]+$/.test(responses[0].body.data?.cartonMark) && responses[0].body.data.cartonMark === responses[1].body.data.cartonMark) const [state] = await snapshot([order]) check('同键并发合箱只扣一次且原箱新箱各一件', null, Number(state.cartons.total) === 2 && rows(state.cartons).every(row => Number(row.totalQuantity) === 1 && row.status === 'AVAILABLE') && rows(state.summary).some(row => row.skuCode === order.sku && Number(row.availableQuantity) === 2 && Number(row.cartonQuantity) === 1)) const original = responses.find(ok)?.body.data?.cartonMark const replay = await request(picker.token, 'POST', MERGE, body) check('同键并发合箱后重试仍返回原箱', replay, ok(replay) && Boolean(original) && replay.body.data?.cartonMark === original) check('同键合箱重放后全部数据不变', replay, JSON.stringify(stableState([state])) === JSON.stringify(stableState(await snapshot([order])))) } }) await scenario('相同SKU跨客户跨仓隔离', async () => { if (!foreignPicker?.token || !otherWarehouse) throw new Error('第二仓操作员未就绪') const sku = `${prefix}-SHARED-SKU` const a = await newOrder('同码客户A本仓', sku, 2) const b = await newOrder('同码客户B本仓', sku, 3, tokenB) const c = await newOrder('同码客户A异仓', sku, 5, merchantToken, String(otherWarehouse.id)) for (const [operator, expected] of [[picker, [a, b]], [foreignPicker, [c]]]) { const found = await good(operator.token, 'POST', '查询同码不同归属的PDA候选', `${RECEIPTS}/sku-candidates`, { skuCode: sku }) check('PDA同码候选按仓隔离且各单数量独立', null, found.candidates.length === expected.length && expected.every(order => found.candidates.some(row => row.returnNo === order.returnNo && Number(row.skuExpectedQuantity) === order.quantity && Number(row.skuReceivedQuantity) === 0))) } const beforeOthers = await snapshot([b, c]) await receive(a) check('同码A签收不改变B客户或异仓数据', null, JSON.stringify(stableState(beforeOthers)) === JSON.stringify(stableState(await snapshot([b, c])))) await receive(b); await receive(c, foreignPicker) for (const order of [a, b, c]) { const data = await good(order.token, 'GET', '查询同码客户仓库维度库存', url(`${INVENTORY}/summary`, { skuCode: sku, overseasWarehouseId: order.warehouseId, page: 1, size: 20 })) check('同码库存只返回当前客户当前仓的准确数量', null, Number(data.total) === 1 && rows(data).length === 1 && rows(data)[0].skuCode === sku && String(rows(data)[0].overseasWarehouseId) === String(order.warehouseId) && Number(rows(data)[0].availableQuantity) === order.quantity && Number(rows(data)[0].cartonQuantity) === 0) } for (const [token, expected] of [[merchantToken, [a, c]], [tokenB, [b]]]) { const data = await good(token, 'GET', '客户按同码查询自己全部退货单', url(ORDERS, { skuCode: sku, page: 1, size: 20 })) check('客户同码列表不夹入其他客户单据', null, Number(data.total) === expected.length && rows(data).length === expected.length && expected.every(order => rows(data).some(row => row.returnNo === order.returnNo))) } await unchanged('相同SKU也不能跨客户合箱', [a, b, c], () => request(picker.token, 'POST', MERGE, mergeBody([a, b], sku, 1)), [409]) await unchanged('相同SKU也不能跨仓合箱', [a, b, c], () => request(picker.token, 'POST', MERGE, mergeBody([a, c], sku, 1)), [404]) }) await scenario('身份入口权限', async () => { const order = await newOrder('权限边界', `${prefix}-AUTH`, 1) for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]], ['平台管理员', context.token, [403]]]) { await unchanged(`${name}不能调用PDA签收`, [order], () => request(token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order)), statuses) } await receive(order) for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]]]) { await unchanged(`${name}不能调用PDA合箱`, [order], () => request(token, 'POST', MERGE, mergeBody([order], order.sku, 1)), statuses) } for (const [name, token] of [['客户', merchantToken], ['PDA', picker.token]]) { for (const path of [PLATFORM_INVENTORY, `${PLATFORM_INVENTORY}/cartons/${encodeURIComponent(order.cartonMark)}`, `${PLATFORM_INVENTORY}/cartons/${encodeURIComponent(order.cartonMark)}/label`, `${PLATFORM_INVENTORY}/movements`]) { const validPath = path === PLATFORM_INVENTORY || path.endsWith('/movements') ? url(path, { page: 1, size: 20, customerId: context.context.merchantQualificationFixture.tenantId, skuCode: order.sku }) : path await unchanged(`${name}禁止读取平台退货入口${path.split('/').at(-1)}`, [order], () => request(token, 'GET', validPath), [403]) } } }) await scenario('签收流水与余额核对', async () => { const order = await newOrder('流水核对', `${prefix}-LEDGER`, 3) const customerId = context.context.merchantQualificationFixture.tenantId const ledger = () => good(context.token, 'GET', '读取本轮签收流水', url(`${PLATFORM_INVENTORY}/movements`, { customerId, overseasWarehouseId: warehouse.id, skuCode: order.sku, page: 1, size: 200 })) check('未签收前本轮SKU无流水', null, exactReceiptLedger(await ledger(), [])) const body = receiptBody(order, 2) const signed = await good(picker.token, 'POST', '流水场景首次实收两件', `${RECEIPTS}/confirm`, body) const entry = { customerId, overseasWarehouseId: warehouse.id, skuCode: order.sku, movementType: 'RECEIPT', referenceNo: signed.receiptNo, totalDelta: 2, availableDelta: 2 } check('首次签收只生成一条两件流水且引用签收单', null, exactReceiptLedger(await ledger(), [entry])) const withLedger = async () => ({ inventory: await snapshot([order]), ledger: await ledger() }) await verifyNoMutation({ snapshot: withLedger, action: () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, body), check, name: '签收重放不重复产生流水', expected: result => ok(result) && result.body.data?.receiptNo === signed.receiptNo }) await verifyNoMutation({ snapshot: withLedger, action: () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order, 2)), check, name: '超收失败不留下流水', expected: result => rejected(result, [409]) }) const final = await good(picker.token, 'POST', '流水场景第二批实收一件', `${RECEIPTS}/confirm`, { ...receiptBody(order, 1), unitCode: `${order.unitCode}-SECOND` }) const data = await ledger() check('两批签收流水业务引用唯一且分别两件一件', null, exactReceiptLedger(data, [entry, { ...entry, referenceNo: final.receiptNo, totalDelta: 1, availableDelta: 1 }]) && signed.receiptNo !== final.receiptNo) const [state] = await snapshot([order]) check('流水增量合计等于订单实收和SKU余额', null, rows(data).reduce((sum, row) => sum + Number(row.totalDelta), 0) === 3 && rows(data).reduce((sum, row) => sum + Number(row.availableDelta), 0) === 3 && Number(state.detail.order.receivedQuantity) === 3 && rows(state.summary).some(row => Number(row.availableQuantity) === 3 && Number(row.totalQuantity) === 3)) }) 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 [ ['已存在箱号', { unitType: 'BOX', unitCode: a.cartonMark, items: [{ skuCode: sku, receivedQuantity: 1, boxCount: 1 }] }], ['缺少幂等键', { 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) } } } }