fix(order): make merchant draft edits and cancellations versioned and idempotent

This commit is contained in:
2026-10-02 21:33:39 +08:00
parent 4b531c7e22
commit 58dfcc21f6
10 changed files with 1227 additions and 28 deletions
+468 -5
View File
@@ -13,9 +13,10 @@
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { randomUUID, createHash } from 'node:crypto'
import { resolve } from 'node:path'
import { createInterface } from 'node:readline/promises'
import { inflateRawSync } from 'node:zlib'
const args = parseArgs(process.argv.slice(2))
if ((args.environment && args.environment !== 'test') || args['overseas-gateway'] || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Only one TEST gateway is supported; remove regional options')
@@ -84,6 +85,7 @@ let merchantToken
let merchantRefreshToken
let platformToken
let existingClientToken
let orderAcceptanceFixture
let ownerPasswordNeedsRestore = false
let temporaryOwnerPassword
@@ -97,6 +99,8 @@ try {
await rbacAndMemberModule()
await orderLifecycleModule()
await isolationModule()
await orderMutationEvidenceModule()
await orderExcelImportModule()
await notificationInboxModule()
await accountSecurityModule()
report.success = report.steps.every((step) => step.passed)
@@ -1211,8 +1215,11 @@ async function orderLifecycleModule() {
editedPayload.customerRemark = `edited-${marker}`
editedPayload.cargoes[0].cargoRemark = `edited-cargo-${marker}`
editedPayload.cargoes[0].model = `MODEL-EDIT-${marker}`.slice(0, 64)
const editBefore = await ownedOrderSnapshot('order-lifecycle', 'freeze original draft edit version', orderId)
const editCommand = orderMutationCommand(editBefore, { draft: editedPayload }, newIdempotencyKey('edit-draft'))
recordOrderMutationRequest('initial lifecycle edit', { method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, ...editCommand }, editBefore)
await apiStep('order-lifecycle', 'edit draft', {
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, body: editedPayload,
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, ...editCommand,
}, ({ json }) => requireSuccess(json), () => ({ orderId, customerRemark: editedPayload.customerRemark }))
const detail = await apiStep('order-lifecycle', 'order detail preserves complete product fields', {
@@ -1231,6 +1238,7 @@ async function orderLifecycleModule() {
cargo: cargoEvidence(json.data.cargoes[0]),
}))
const orderNo = detail.json.data.order.orderNo
orderAcceptanceFixture = { masterData, payload: editedPayload, orderId }
await apiStep('order-lifecycle', 'order list contains edited draft', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&orderNo=${encodeURIComponent(orderNo)}`, token: merchantToken,
@@ -1321,7 +1329,9 @@ async function orderLifecycleModule() {
}, ({ json }) => ({ orderId: 1, rejectionCode: json?.code }))
await softApiStep('order-boundary', 'submitted order cannot be edited by client', {
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, body: editedPayload,
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken,
...orderMutationCommand(await ownedOrderSnapshot('order-boundary', 'freeze submitted edit attempt', orderId),
{ draft: editedPayload }, newIdempotencyKey('submitted-edit')),
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0409', 'business code')
@@ -1356,10 +1366,13 @@ async function orderLifecycleModule() {
}, ({ json }) => ({ orderId: json.data }))
const cancelOrderId = cancelDraft.json.data
report.resources.orderIds.push(cancelOrderId)
const cancelBefore = await ownedOrderSnapshot('order-lifecycle', 'freeze cancellation version', cancelOrderId)
const cancelCommand = orderMutationCommand(cancelBefore, { reason: `客户端自动化取消 ${marker}` }, newIdempotencyKey('cancel-draft'))
recordOrderMutationRequest('initial lifecycle cancellation', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, ...cancelCommand }, cancelBefore)
await apiStep('order-lifecycle', 'cancel second draft', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, token: merchantToken,
body: { reason: `客户端自动化取消 ${marker}` },
...cancelCommand,
}, ({ json }) => requireSuccess(json), () => ({ orderId: cancelOrderId }))
await apiStep('order-lifecycle', 'cancelled status is persisted', {
@@ -1371,7 +1384,8 @@ async function orderLifecycleModule() {
await softApiStep('order-boundary', 'cancelled order cannot be cancelled again', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, token: merchantToken,
body: { reason: `重复取消 ${marker}` },
...orderMutationCommand(await ownedOrderSnapshot('order-boundary', 'freeze repeat cancellation attempt', cancelOrderId),
{ reason: `重复取消 ${marker}` }, newIdempotencyKey('repeat-cancel')),
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0409', 'business code')
@@ -1992,6 +2006,455 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) {
}
}
// BEGIN ORDER ACCEPTANCE HELPERS — loaded without CLI side effects by the direct contract.
function orderCanonical(value) {
if (Array.isArray(value)) return value.map(orderCanonical)
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, orderCanonical(value[key])]))
return value
}
function orderDigest(value) { return createHash('sha256').update(JSON.stringify(orderCanonical(value))).digest('hex') }
function assertOrderUnchanged(before, after) {
assertEqual(orderDigest(after), orderDigest(before), 'order, cargo, parties, notes and history must remain unchanged')
}
function assertOrderDetail(data, id) {
assertEqual(String(data?.order?.id), String(id), 'owned order identity')
for (const field of ['cargoes', 'parties', 'internalNotes', 'statusHistory']) assertTrue(Array.isArray(data?.[field]), `complete ${field} snapshot required`)
assertPresent(data.order.version, 'order version')
}
function orderMutationCommand(snapshot, payload, key) {
assertTrue(Number.isInteger(snapshot?.order?.version) && snapshot.order.version >= 0, 'frozen order version required')
assertTrue(typeof key === 'string' && key.length > 0, 'frozen idempotency key required')
const command = structuredClone({ headers: { 'Idempotency-Key': key }, body: { ...payload, expectedVersion: snapshot.order.version } })
const freeze = value => { if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value) } }
freeze(command)
return command
}
function assertImportResult(data, mode, expected) {
assertEqual(data?.mode, mode, 'import mode')
assertPresent(data?.batchNo, 'batch number')
assertPresent(data?.sourceFileId, 'source file')
const result = data?.result
assertEqual(result?.total, expected.length, 'import total')
assertEqual(result?.succeeded, expected.filter(Boolean).length, 'import succeeded')
assertEqual(result?.failed, expected.filter(value => !value).length, 'import failed')
assertEqual(result?.items?.length, expected.length, 'import item count')
result.items.forEach((item, index) => {
assertEqual(item.index, index, 'result index')
assertEqual(item.clientRowId, `excel-row-${index + 3}`, 'physical Excel row')
assertEqual(item.success, expected[index], 'row success')
if (item.success) { assertPresent(item.orderId, 'created order ID'); assertPresent(item.orderNo, 'created order number') }
else {
assertEqual(item.orderId, null, 'failed row has no order')
assertEqual(item.errorCode, 'A0422', 'invalid row code')
assertPresent(item.errorMessage, 'row error reason')
}
})
if (result.failed) assertPresent(data.errorReportFileId, 'downloadable error report')
}
// Read the downloaded POI template, preserving its headers, options and validations.
// ZIP64/encrypted archives are deliberately rejected; template fixtures are small XLSX files.
function orderZipRead(bytes) {
const input = Buffer.from(bytes)
let end = input.length - 22
while (end >= Math.max(0, input.length - 65557) && input.readUInt32LE(end) !== 0x06054b50) end--
assertTrue(end >= 0 && input.readUInt32LE(end) === 0x06054b50, 'valid XLSX ZIP directory')
const entries = new Map()
let offset = input.readUInt32LE(end + 16)
for (let index = 0; index < input.readUInt16LE(end + 10); index++) {
assertEqual(input.readUInt32LE(offset), 0x02014b50, 'ZIP entry signature')
const method = input.readUInt16LE(offset + 10), size = input.readUInt32LE(offset + 20)
const length = input.readUInt32LE(offset + 24), nameLength = input.readUInt16LE(offset + 28)
const name = input.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
const local = input.readUInt32LE(offset + 42)
assertTrue(!(input.readUInt16LE(offset + 8) & 1) && [0, 8].includes(method) && length <= 16 * 1024 * 1024, 'supported bounded ZIP entry')
assertEqual(input.readUInt32LE(local), 0x04034b50, 'ZIP local signature')
const start = local + 30 + input.readUInt16LE(local + 26) + input.readUInt16LE(local + 28)
const packed = input.subarray(start, start + size)
const content = method === 8 ? inflateRawSync(packed, { maxOutputLength: 16 * 1024 * 1024 }) : packed
assertEqual(content.length, length, 'ZIP uncompressed size')
assertEqual(orderCrc(content), input.readUInt32LE(offset + 16), 'ZIP checksum')
assertTrue(!entries.has(name), 'unique ZIP entry')
entries.set(name, content.toString('utf8'))
offset += 46 + nameLength + input.readUInt16LE(offset + 30) + input.readUInt16LE(offset + 32)
}
return entries
}
function orderCrc(bytes) {
let crc = 0xffffffff
for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0) }
return (crc ^ 0xffffffff) >>> 0
}
function orderZipWrite(entries) {
const local = [], directory = []
let offset = 0
for (const [name, text] of entries) {
const filename = Buffer.from(name), data = Buffer.from(text), crc = orderCrc(data)
const header = Buffer.alloc(30)
header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4); header.writeUInt16LE(0x800, 6)
header.writeUInt32LE(crc, 14); header.writeUInt32LE(data.length, 18); header.writeUInt32LE(data.length, 22); header.writeUInt16LE(filename.length, 26)
const central = Buffer.alloc(46)
central.writeUInt32LE(0x02014b50); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x800, 8)
central.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24)
central.writeUInt16LE(filename.length, 28); central.writeUInt32LE(offset, 42)
local.push(header, filename, data); directory.push(central, filename)
offset += header.length + filename.length + data.length
}
const central = Buffer.concat(directory), end = Buffer.alloc(22)
end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries.size, 8); end.writeUInt16LE(entries.size, 10)
end.writeUInt32LE(central.length, 12); end.writeUInt32LE(offset, 16)
return Buffer.concat([...local, central, end])
}
function orderXmlDecode(text) {
return text.replace(/&#(x[0-9a-f]+|[0-9]+);|&(lt|gt|quot|apos|amp);/gi, (match, code, named) => code
? String.fromCodePoint(code[0].toLowerCase() === 'x' ? parseInt(code.slice(1), 16) : Number(code))
: ({ lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' })[named.toLowerCase()])
}
function orderXmlEncode(text) { return String(text).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[char]) }
function orderColumn(index) {
let result = ''
for (let value = index + 1; value; value = Math.floor((value - 1) / 26)) result = String.fromCharCode(65 + (value - 1) % 26) + result
return result
}
function orderSheetRows(entries, path = 'xl/worksheets/sheet1.xml') {
const strings = [...(entries.get('xl/sharedStrings.xml') ?? '').matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map(item =>
[...item[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map(text => orderXmlDecode(text[1])).join(''))
const sheet = entries.get(path)
assertPresent(sheet, `template ${path}`)
return [...sheet.matchAll(/<row\b[^>]*\br="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)].map(row => ({ number: Number(row[1]), cells:
[...row[2].matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)].map(cell => {
const ref = cell[1].match(/\br="([A-Z]+)\d+"/)?.[1]
const content = cell[2] ?? ''
const value = content.match(/<v\b[^>]*>([\s\S]*?)<\/v>/)?.[1]
const text = /\bt="s"/.test(cell[1]) ? strings[Number(value)] : /\bt="inlineStr"/.test(cell[1])
? [...content.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map(item => orderXmlDecode(item[1])).join('') : orderXmlDecode(value ?? '')
return { ref, text }
}) }))
}
function orderWorkbookRows(template, rows) {
const entries = new Map(template), sheet = entries.get('xl/worksheets/sheet1.xml')
const headers = [...sheet.matchAll(/<row\b[^>]*\br="([12])"[^>]*>[\s\S]*?<\/row>/g)].map(row => row[0]).join('')
assertTrue(headers.includes('r="1"') && headers.includes('r="2"'), 'both original header rows preserved')
const data = rows.map((row, index) => `<row r="${index + 3}">${row.map((text, col) => `<c r="${orderColumn(col)}${index + 3}" t="inlineStr"><is><t xml:space="preserve">${orderXmlEncode(text)}</t></is></c>`).join('')}</row>`).join('')
entries.set('xl/worksheets/sheet1.xml', sheet.replace(/<sheetData\b[^>]*>[\s\S]*?<\/sheetData>/, `<sheetData>${headers}${data}</sheetData>`))
return orderZipWrite(entries)
}
function importRollbackRows(validFirst, validSecond) {
const invalid = [...validFirst]; invalid[10] = '' // mandatory cargo name: seed only the second row
const changed = [...validSecond]; changed[30] += '-changed' // SaveRequest.customerRemark participates in CREATE_DRAFT hash
return { seed: [invalid, validSecond], conflict: [validFirst, changed], retry: [validFirst, validSecond] }
}
// END ORDER ACCEPTANCE HELPERS
async function ownedOrderSnapshot(module, name, id, expected, validate = () => {}) {
const response = await apiStep(module, name, {
path: `/api/merchant/v1/orders/first-leg/${id}`, token: merchantToken,
}, ({ json }) => {
requireSuccess(json); assertOrderDetail(json.data, id)
if (expected) assertOrderUnchanged(expected, json.data)
validate(json.data)
}, ({ json }) => ({ orderId: String(id), status: json.data?.order?.status, version: json.data?.order?.version, snapshotSha256: orderDigest(json.data) }))
return response.json.data
}
function recordOrderMutationRequest(name, request, before) {
report.orderMutationRequests ??= []
report.orderMutationRequests.push({ name, method: request.method, path: request.path,
idempotencyKey: request.headers?.['Idempotency-Key'] ?? null, body: structuredClone(request.body),
beforeVersion: before.order.version, beforeSnapshotSha256: orderDigest(before) })
}
async function rejectOrderMutation(name, id, request, codes, statuses = [200]) {
const before = await ownedOrderSnapshot('order-mutation-evidence', `${name}: before`, id)
recordOrderMutationRequest(name, request, before)
await apiStep('order-mutation-evidence', name, request, ({ json, status }) => {
assertTrue(statuses.includes(status), `expected HTTP ${statuses}, actual=${status}`)
assertTrue(codes.includes(String(json?.code)), `expected ${codes}, actual=${json?.code}`)
}, ({ json }) => ({ orderId: String(id), rejectionCode: json?.code }))
await ownedOrderSnapshot('order-mutation-evidence', `${name}: all facts unchanged`, id, before)
}
async function orderMutationEvidenceModule() {
const module = 'order-mutation-evidence', base = '/api/merchant/v1/orders/first-leg'
assertPresent(orderAcceptanceFixture, 'completed order lifecycle fixture')
assertPresent(existingClientToken, 'independent client session')
const payload = structuredClone(orderAcceptanceFixture.payload)
payload.customerRemark = `mutation-${marker}`
const created = await apiStep(module, 'create owned mutable draft for isolation and cancellation', {
method: 'POST', path: base, token: merchantToken, body: payload, headers: { 'Idempotency-Key': newIdempotencyKey('mutation-draft') },
}, ({ json }) => { requireSuccess(json); assertPresent(json.data, 'draft ID') }, ({ json }) => ({ orderId: json.data }))
const id = created.json.data
report.resources.orderIds.push(id)
const original = await ownedOrderSnapshot(module, 'freeze draft page version and request', id)
const editPayload = structuredClone(payload); editPayload.customerRemark += '-edited'
const editCommand = orderMutationCommand(original, { draft: editPayload }, newIdempotencyKey('versioned-edit'))
const editRequest = { method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...editCommand }
recordOrderMutationRequest('versioned draft edit', editRequest, original)
await apiStep(module, 'versioned draft edit succeeds', editRequest, ({ json }) => requireSuccess(json),
() => ({ orderId: id, expectedVersion: original.order.version }))
const edited = await ownedOrderSnapshot(module, 'edited draft full snapshot', id, null, data => {
assertEqual(data.order.status, 'DRAFT', 'edit stays draft')
assertTrue(data.order.version > original.order.version, 'edit increments version')
assertEqual(data.order.customerRemark, editPayload.customerRemark, 'edited source remark')
for (const field of ['id', 'orderNo', 'inboundCode', 'tenantId', 'customerId']) assertEqual(data.order[field], original.order[field], `stable edit ${field}`)
assertEqual(data.cargoes.length, editPayload.cargoes.length, 'complete edited cargo set')
data.cargoes.forEach((cargo, index) => {
for (const field of ['skuNo', 'packageCount', 'model', 'productImageFileId']) assertEqual(cargo[field], editPayload.cargoes[index][field], `edited cargo ${field}`)
})
const oldIds = new Set(original.statusHistory.map(item => String(item.id)))
const added = data.statusHistory.filter(item => !oldIds.has(String(item.id)))
assertOrderUnchanged(original.statusHistory, data.statusHistory.filter(item => oldIds.has(String(item.id))))
assertEqual(added.length, 1, 'single edit audit'); assertEqual(added[0].action, 'UPDATE', 'edit audit action')
})
recordOrderMutationRequest('original edit replay', editRequest, original)
await apiStep(module, 'original edit key and frozen payload replay successfully', editRequest, ({ json }) => requireSuccess(json),
() => ({ orderId: id, expectedVersion: original.order.version, replayed: true }))
await ownedOrderSnapshot(module, 'edit replay adds no version cargo or history', id, edited)
const changedEdit = structuredClone(editCommand); changedEdit.body.draft.customerRemark += '-conflict'
await rejectOrderMutation('edit original key different payload conflicts', id, {
method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...changedEdit,
}, ['A0409'])
await rejectOrderMutation('old page version cannot overwrite newer draft', id, {
method: 'PUT', path: `${base}/${id}`, token: merchantToken,
...orderMutationCommand(original, { draft: payload }, newIdempotencyKey('stale-edit')),
}, ['A0409'])
for (const operation of ['edit', 'cancel']) {
const method = operation === 'edit' ? 'PUT' : 'POST', path = `${base}/${id}${operation === 'edit' ? '' : '/cancel'}`
const command = orderMutationCommand(edited, operation === 'edit' ? { draft: editPayload } : { reason: 'missing protocol guard' }, newIdempotencyKey(`missing-${operation}`))
const withoutVersion = structuredClone(command); delete withoutVersion.body.expectedVersion
await rejectOrderMutation(`${operation} missing version is rejected`, id, { method, path, token: merchantToken, ...withoutVersion }, ['A0422'])
await rejectOrderMutation(`${operation} missing key is rejected`, id, { method, path, token: merchantToken, body: command.body }, ['A0400'], [200, 400])
}
await rejectOrderMutation('other customer cannot edit owned draft', id, {
method: 'PUT', path: `${base}/${id}`, token: existingClientToken,
...orderMutationCommand(edited, { draft: payload }, newIdempotencyKey('foreign-edit')),
}, ['A0403', 'A0404'])
await rejectOrderMutation('other customer cannot cancel owned draft', id, {
method: 'POST', path: `${base}/${id}/cancel`, token: existingClientToken,
...orderMutationCommand(edited, { reason: `foreign-${marker}` }, newIdempotencyKey('foreign-cancel')),
}, ['A0403', 'A0404'])
const invalid = structuredClone(payload); invalid.cargoes[0].productImageFileId = -1
await rejectOrderMutation('invalid product image edit rolls back all facts', id, {
method: 'PUT', path: `${base}/${id}`, token: merchantToken,
...orderMutationCommand(edited, { draft: invalid }, newIdempotencyKey('invalid-edit')),
}, ['A0422'])
await rejectOrderMutation('submitted order cannot be overwritten as draft', orderAcceptanceFixture.orderId, {
method: 'PUT', path: `${base}/${orderAcceptanceFixture.orderId}`, token: merchantToken,
...orderMutationCommand(await ownedOrderSnapshot(module, 'freeze submitted order edit request', orderAcceptanceFixture.orderId),
{ draft: payload }, newIdempotencyKey('submitted-edit-evidence')),
}, ['A0409'])
await rejectOrderMutation('blank cancellation reason preserves all facts', id, {
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken,
...orderMutationCommand(edited, { reason: ' ' }, newIdempotencyKey('empty-reason')),
}, ['A0422'])
await rejectOrderMutation('old page version cannot cancel updated draft', id, {
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken,
...orderMutationCommand(original, { reason: 'stale page' }, newIdempotencyKey('stale-cancel')),
}, ['A0409'])
const before = await ownedOrderSnapshot(module, 'before valid cancellation', id)
const reason = `F06-cancel-${marker}`
const cancelCommand = orderMutationCommand(before, { reason }, newIdempotencyKey('versioned-cancel'))
const cancelRequest = { method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...cancelCommand }
recordOrderMutationRequest('versioned cancellation', cancelRequest, before)
await apiStep(module, 'cancel owned draft with reason', cancelRequest, ({ json }) => requireSuccess(json), () => ({ orderId: id, reason }))
await apiStep(module, 'cancellation persists one attributed history and preserves cargo', {
path: `${base}/${id}`, token: merchantToken,
}, ({ json }) => {
requireSuccess(json); assertOrderDetail(json.data, id)
assertEqual(json.data.order.status, 'CANCELLED', 'cancelled status')
assertTrue(json.data.order.version > before.order.version, 'version increases')
for (const field of ['cargoes', 'parties', 'internalNotes']) assertOrderUnchanged(before[field], json.data[field])
for (const field of ['id', 'orderNo', 'inboundCode', 'tenantId', 'customerId']) assertEqual(json.data.order[field], before.order[field], `stable ${field}`)
const oldIds = new Set(before.statusHistory.map(item => String(item.id)))
const added = json.data.statusHistory.filter(item => !oldIds.has(String(item.id)))
assertEqual(added.length, 1, 'one cancellation history')
assertOrderUnchanged(before.statusHistory, json.data.statusHistory.filter(item => oldIds.has(String(item.id))))
assertEqual(added[0].action, 'CANCELLED', 'cancellation action'); assertEqual(added[0].reason, reason, 'cancellation reason')
assertEqual(added[0].fromStatus, 'DRAFT', 'from draft'); assertEqual(added[0].toStatus, 'CANCELLED', 'to cancelled')
assertPresent(added[0].operatorId, 'operator'); assertPresent(added[0].occurredAt, 'server operation time')
}, ({ json }) => ({ orderId: id, status: json.data?.order?.status, snapshotSha256: orderDigest(json.data) }))
const cancelled = await ownedOrderSnapshot(module, 'freeze cancelled facts before replay', id)
recordOrderMutationRequest('original cancellation replay', cancelRequest, before)
await apiStep(module, 'original cancellation key payload and version replay successfully', cancelRequest,
({ json }) => requireSuccess(json), () => ({ orderId: id, expectedVersion: before.order.version, replayed: true }))
await ownedOrderSnapshot(module, 'cancel replay creates no second history or version', id, cancelled)
const changedCancel = structuredClone(cancelCommand); changedCancel.body.reason += '-conflict'
await rejectOrderMutation('cancellation original key different reason conflicts', id, {
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...changedCancel,
}, ['A0409'])
await rejectOrderMutation('repeat cancellation cannot add history', id, {
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken,
...orderMutationCommand(cancelled, { reason }, newIdempotencyKey('new-key-repeat-cancel')),
}, ['A0409'])
await rejectOrderMutation('cancelled draft cannot be submitted', id, {
method: 'POST', path: `${base}/${id}/submit`, token: merchantToken, headers: { 'Idempotency-Key': newIdempotencyKey('cancelled-submit-proof') },
}, ['A0409'])
report.orderMutationLimitations = [
'Requires deployed Merchant edit/cancel versioned idempotent protocol; local contracts do not prove TEST deployment.',
'Irreversible-fulfillment cancellation is not exercised by these draft/submitted fixtures.',
]
}
async function importOrderSet(name, expectedIds) {
const ids = [], seen = new Set()
let total = null
for (let page = 1; ; page++) {
await apiStep('order-excel-import', `${name}: complete order page ${page}`, {
path: `/api/merchant/v1/orders/first-leg?page=${page}&size=200`, token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertTrue(Array.isArray(json.data?.records), 'order page records required')
assertEqual(Number(json.data.page), page, 'order page identity')
const count = Number(json.data.total)
assertTrue(Number.isSafeInteger(count) && count >= 0, 'valid order total')
if (total === null) total = count
assertEqual(count, total, 'stable order page total')
for (const row of json.data.records) {
assertPresent(row.orderId, 'listed order ID')
const id = String(row.orderId)
assertTrue(!seen.has(id), 'no duplicate order across pages'); seen.add(id); ids.push(id)
}
assertTrue(ids.length <= total, 'order pages do not exceed total')
if (page * 200 >= total) {
assertEqual(ids.length, total, 'complete order set')
if (expectedIds) assertOrderUnchanged([...expectedIds].sort(), [...ids].sort())
} else assertEqual(json.data.records.length, 200, 'full intermediate order page')
}, () => ({ page, total, collected: ids.length, orderIdsSha256: orderDigest([...ids].sort()) }))
if (page * 200 >= total) return ids
}
}
async function orderExcelImportModule() {
const module = 'order-excel-import', base = '/api/merchant/v1/orders/first-leg/batch-imports'
const { masterData, payload } = orderAcceptanceFixture
let template
await apiStep(module, 'download and inspect actual Chinese customer template', {
path: `${base}/template`, token: merchantToken, responseType: 'bytes',
}, ({ status, bytes, headers }) => {
assertEqual(status, 200, 'template HTTP status')
assertTrue((headers.get('content-type') ?? '').includes('spreadsheetml'), 'XLSX content type')
template = orderZipRead(bytes)
const header = orderSheetRows(template).find(row => row.number === 2)
assertEqual(header?.cells?.length, 31, 'current 31-column template')
assertEqual(header.cells[0].text, '运输方式 *', 'Chinese first header')
assertEqual(header.cells[30].text, '客户备注', 'Chinese final header')
assertTrue(template.get('xl/workbook.xml').includes('选项数据'), 'server-generated option sheet')
}, ({ bytes }) => ({ sizeBytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }))
const options = new Set(orderSheetRows(template, 'xl/worksheets/sheet2.xml').flatMap(row => row.cells.map(cell => cell.text)))
const businessLabel = (name, code) => !name || name.toLowerCase() === code.toLowerCase() ? code : `${name}|${code}`
const receiving = businessLabel(masterData.receiving.warehouseName, masterData.receiving.warehouseCode)
const destination = businessLabel(masterData.destination.warehouseName, masterData.destination.warehouseCode)
const channel = businessLabel(masterData.channel.channelName, masterData.channel.channelCode)
for (const value of [receiving, destination, channel]) assertTrue(options.has(value), `authoritative template option ${value}`)
const makeRow = suffix => [
'海运', '', '自行快递', receiving, channel, '亚马逊FBA', destination, '', '', '',
`Excel-${suffix}`, '纸箱', '2', '20.5', '0.024', '塑料', '否', '否', `SKU-${suffix}`, '123456789012',
`SHIP-${suffix}`, `REF-${suffix}`, '买单报关', payload.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税',
'散货拼箱', '否', `SHIP-${suffix}`, `REF-${suffix}`, '普通头程', '', `${marker}-${suffix}`,
]
const upload = async (name, mode, key, bytes, validate, filename = 'orders.xlsx') => {
const form = new FormData()
form.append('file', new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), filename)
return apiStep(module, name, {
method: 'POST', path: `${base}?mode=${mode}`, token: merchantToken, headers: { 'Idempotency-Key': key }, body: form,
}, validate, ({ json }) => ({ batchNo: json?.data?.batchNo, mode, result: json?.data?.result, sourceFileId: json?.data?.sourceFileId,
errorReportFileId: json?.data?.errorReportFileId, requestBytesSha256: createHash('sha256').update(bytes).digest('hex') }))
}
const rejection = code => ({ json, status }) => {
assertEqual(status, 200, 'HTTP status'); assertEqual(String(json?.code), code, 'rejection business code')
}
const success = (mode, rows) => ({ json }) => { requireSuccess(json); assertImportResult(json.data, mode, rows) }
const verifyDraft = async (id, row, name) => {
await apiStep(module, name, { path: `/api/merchant/v1/orders/first-leg/${id}`, token: merchantToken }, ({ json }) => {
requireSuccess(json); assertOrderDetail(json.data, id)
assertEqual(json.data.order.status, 'DRAFT', 'import creates draft only')
assertEqual(String(json.data.order.tenantId), String(report.resources.tenantId), 'authenticated customer scope')
assertEqual(json.data.order.customerRemark, row[30], 'exact source remark')
assertEqual(json.data.order.referenceId, row[27], 'exact source reference')
assertEqual(String(json.data.order.receivingWarehouseId), String(masterData.receiving.id), 'authoritative receiving warehouse')
assertEqual(String(json.data.order.destinationWarehouseId), String(masterData.destination.id), 'authoritative destination')
assertEqual(json.data.order.channelCode, masterData.channel.channelCode, 'authoritative channel')
assertEqual(json.data.cargoes.length, 1, 'one Excel row creates one order cargo')
assertEqual(json.data.cargoes[0].skuNo, row[18], 'source SKU')
assertEqual(Number(json.data.cargoes[0].packageCount), 2, 'source package count')
assertTrue(!json.data.statusHistory.some(item => item.toStatus === 'SUBMITTED'), 'import never submits')
}, ({ json }) => ({ orderId: String(id), status: json.data?.order?.status, snapshotSha256: orderDigest(json.data) }))
}
const initial = await importOrderSet('before import matrix')
const rows = importRollbackRows(makeRow('rollback-first'), makeRow('rollback-second'))
const key = newIdempotencyKey('excel-transaction')
const seedBytes = orderWorkbookRows(template, rows.seed)
const seed = await upload('PARTIAL preserves only valid second row', 'PARTIAL', key, seedBytes, success('PARTIAL', [false, true]))
const retainedId = String(seed.json.data.result.items[1].orderId)
report.resources.orderIds.push(retainedId)
const seeded = [...initial, retainedId]
await importOrderSet('PARTIAL exactly one new order', seeded)
await verifyDraft(retainedId, rows.seed[1], 'PARTIAL source fields and customer match')
const retained = await ownedOrderSnapshot(module, 'seed second row full snapshot', retainedId)
const replay = await upload('PARTIAL same key bytes returns same order', 'PARTIAL', key, seedBytes, response => {
success('PARTIAL', [false, true])(response)
assertEqual(String(response.json.data.result.items[1].orderId), retainedId, 'replayed order identity')
})
await importOrderSet('PARTIAL replay adds no orders', seeded)
await ownedOrderSnapshot(module, 'PARTIAL replay preserves full existing order', retainedId, retained)
const errorData = replay.json.data
await apiStep(module, 'download actual row error workbook', {
path: `/api/merchant/v1/files/order-batch-imports/${encodeURIComponent(errorData.batchNo)}/error-reports/${errorData.errorReportFileId}`,
token: merchantToken, responseType: 'bytes',
}, ({ status, bytes }) => {
assertEqual(status, 200, 'error workbook download')
const rows = orderSheetRows(orderZipRead(bytes))
assertEqual(rows.length, 2, 'header and exactly one failed row')
assertEqual(rows[1].cells[0].text, '3', 'actual Excel error row')
assertEqual(rows[1].cells[1].text, 'A0422', 'error report code')
assertEqual(rows[1].cells[2].text, errorData.result.items[0].errorMessage, 'error report exact reason')
}, ({ bytes }) => ({ sizeBytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }))
await upload('ATOMIC parse rejection creates no orders', 'ATOMIC', newIdempotencyKey('excel-parse'), seedBytes, rejection('A0422'))
await importOrderSet('ATOMIC parse failure has zero additions', seeded)
// Same tenant/operator/key and physical row IDs; only row 4 customerRemark changes.
// Row 3 passes validation and writes before row 4 CREATE_DRAFT fingerprint conflicts.
await upload('ATOMIC later row idempotency conflict rolls back earlier row', 'ATOMIC', key,
orderWorkbookRows(template, rows.conflict), rejection('A0409'))
await importOrderSet('ATOMIC mid-write conflict leaves no first-row order', seeded)
await ownedOrderSnapshot(module, 'ATOMIC conflict preserves seeded order and history', retainedId, retained)
await upload('ATOMIC retry with original second-row hash succeeds', 'ATOMIC', key,
orderWorkbookRows(template, rows.retry), response => {
success('ATOMIC', [true, true])(response)
assertEqual(String(response.json.data.result.items[1].orderId), retainedId, 'second row reuses original ID')
assertTrue(String(response.json.data.result.items[0].orderId) !== retainedId, 'new first row is distinct')
}).then(async response => {
const firstId = String(response.json.data.result.items[0].orderId)
report.resources.orderIds.push(firstId); seeded.push(firstId)
await importOrderSet('ATOMIC retry adds exactly first row', seeded)
await verifyDraft(firstId, rows.retry[0], 'ATOMIC first row source facts match')
})
await ownedOrderSnapshot(module, 'successful ATOMIC replay preserves second row', retainedId, retained)
const invalidCases = [
['wrong extension', seedBytes, 'orders.csv'], ['corrupt xlsx', Buffer.from('not-an-xlsx'), 'orders.xlsx'],
['over five MiB', Buffer.alloc(5 * 1024 * 1024 + 1), 'orders.xlsx'],
['51 effective rows', orderWorkbookRows(template, Array.from({ length: 51 }, (_, index) => makeRow(`limit-${index}`))), 'orders.xlsx'],
]
const missingHeader = orderZipRead(orderWorkbookRows(template, [makeRow('header')]))
missingHeader.set('xl/worksheets/sheet1.xml', missingHeader.get('xl/worksheets/sheet1.xml').replace(/<row\b[^>]*\br="2"[^>]*>[\s\S]*?<\/row>/, '<row r="2"/>'))
invalidCases.push(['missing Chinese headers', orderZipWrite(missingHeader), 'orders.xlsx'])
for (const [name, bytes, filename] of invalidCases) {
await upload(`reject ${name}`, 'ATOMIC', newIdempotencyKey('excel-invalid'), bytes, rejection('A0422'), filename)
await importOrderSet(`${name}: order set unchanged`, seeded)
}
const forged = makeRow('forged-catalog'); forged[3] = `FORGED-${marker}`
const tampered = new Map(template)
tampered.set('xl/worksheets/sheet2.xml', tampered.get('xl/worksheets/sheet2.xml').replace('</sheetData>', `<row r="9999"><c r="A9999" t="inlineStr"><is><t>${orderXmlEncode(forged[3])}</t></is></c></row></sheetData>`))
await upload('forged hidden option is rejected against authoritative catalog', 'ATOMIC', newIdempotencyKey('excel-forged'),
orderWorkbookRows(tampered, [forged]), rejection('A0422'))
await importOrderSet('forged option adds no orders', seeded)
report.orderImportLimitations = [
'ATOMIC assertions concern Order rows and their public facts; remote source uploads are not rolled back.',
'Template selected options are checked; exhaustive warehouse pagination and cross-customer private-address template comparison remain open.',
'This HTTP matrix does not prove browser import interaction or irreversible order fulfillment cancellation.',
]
}
async function apiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) {
const startedAt = new Date().toISOString()
const started = Date.now()