From 0319bb51a0ee5f503b8abd222c67bce6509e19de Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 00:46:00 +0800 Subject: [PATCH] test(order): verify channel removal using customer-owned price books --- ...ient-test-env-acceptance-contract.test.mjs | 118 ++++++++++- scripts/client-test-env-acceptance.mjs | 183 +++++++++++++++++- 2 files changed, 293 insertions(+), 8 deletions(-) diff --git a/scripts/client-test-env-acceptance-contract.test.mjs b/scripts/client-test-env-acceptance-contract.test.mjs index bafcebad7..effe72aae 100644 --- a/scripts/client-test-env-acceptance-contract.test.mjs +++ b/scripts/client-test-env-acceptance-contract.test.mjs @@ -18,7 +18,7 @@ test('client registration distinguishes incomplete-registration replay from cred const helperSource = source.slice(source.indexOf('// BEGIN ORDER ACCEPTANCE HELPERS'), source.indexOf('// END ORDER ACCEPTANCE HELPERS')) const workflowSource = source.slice(source.indexOf('async function ownedOrderSnapshot('), source.indexOf('async function apiStep(')) function runtime(extra = {}) { - const context = vm.createContext({ Buffer, createHash, inflateRawSync, structuredClone, Blob, FormData, + const context = vm.createContext({ Buffer, createHash, inflateRawSync, deflateRawSync, structuredClone, Blob, FormData, assertEqual: (actual, expected, label) => assert.equal(actual, expected, label), assertTrue: (value, label) => assert.ok(value, label), assertPresent: (value, label) => assert.ok(value !== null && value !== undefined && value !== '', label), @@ -28,6 +28,122 @@ function runtime(extra = {}) { } const plain = value => JSON.parse(JSON.stringify(value)) +function priceBookSource(r) { + const channels=[{channelCode:'MATSON_CLX_TRUCK',sourceSheetName:'美森正班海卡'}, + {channelCode:'HEDE_TRUCK',sourceSheetName:'合德海卡'}] + const rates=channels.map(channel=>({channelCode:channel.channelCode,originWarehouseCode:'YIWU',originWarehouseId:'2', + destinationType:'FBA',destinationCode:'ONT8',unitPrice:10})) + const entries=new Map([ + ['xl/workbook.xml',''], + ['xl/_rels/workbook.xml.rels',''], + ['xl/worksheets/sheet1.xml',''], + ['xl/worksheets/sheet2.xml',''], + ['xl/media/image1.png',Buffer.from([0x89,0x50,0x4e,0x47,0xff,0x00,0x80])], + ]) + const bytes=r.orderZipWrite(entries,true) + const version={id:'100',customerId:'0',status:'ACTIVE',sourceSha256:createHash('sha256').update(bytes).digest('hex')} + return {bytes,entries,reference:{version,channels,rates}} +} + +test('private B removes target sheet only, preserves binary source parts and uses published mapping IDs',()=>{ + const r=runtime(),source=priceBookSource(r) + const b=r.channelGateWorkbook(source.bytes,source.reference,'MATSON_CLX_TRUCK'),entries=r.orderZipRead(b.bytes) + assert.deepEqual(plain(b.removedCodes),['MATSON_CLX_TRUCK']) + assert.doesNotMatch(entries.get('xl/workbook.xml'),/美森正班海卡/) + assert.match(entries.get('xl/workbook.xml'),/合德海卡/) + for(const [name,original] of source.entries)if(name!=='xl/workbook.xml')assert.deepEqual(entries.get(name),original) + assert.deepEqual(plain(r.channelGateMappings({rates:[source.reference.rates[1]]},source.reference)),{ + originMappings:[{originWarehouseCode:'YIWU',warehouseId:'2'}],overseasMappings:[], + }) + assert.throws(()=>r.channelGateWorkbook(source.bytes,{...source.reference,channels:[source.reference.channels[0]]},'MATSON_CLX_TRUCK'),/retain another/) + assert.throws(()=>r.channelGateMappings({rates:[source.reference.rates[1]]},{rates:[{...source.reference.rates[1],originWarehouseId:null}]}),/unique published/) + assert.throws(()=>r.channelGateMappings({rates:[source.reference.rates[1]]},{rates:[source.reference.rates[1],{...source.reference.rates[1],originWarehouseId:'9'}]}),/unique published/) +}) + +test('shared parcel source sheet removes all its channel codes and cannot leave an empty price book',()=>{ + const r=runtime(),source=priceBookSource(r) + source.reference.channels.push({channelCode:'SAME_SHEET_SECOND_CODE',sourceSheetName:'美森正班海卡'}) + assert.deepEqual(plain(r.channelGateWorkbook(source.bytes,source.reference,'MATSON_CLX_TRUCK').removedCodes),['MATSON_CLX_TRUCK','SAME_SHEET_SECOND_CODE']) +}) + +function channelGateHarness(fault='') { + const r=runtime(),source=priceBookSource(r),calls=[],books=new Map(),publicVersions=[structuredClone(source.reference.version)] + const report={resources:{tenantId:'20',orderIds:[]},steps:[{module:'registration',name:'enterprise self-registration',passed:true,evidence:{tenantId:'20'}}], + orderSubmissionGates:{balance:{restoration:'RESTORED_WITH_AUDIT'},remaining:['Channel-offline requires fixture','Other pending scope']}} + const order={order:{id:'42',tenantId:'20',orderNo:'FL42',status:'DRAFT',version:7,channelCode:'MATSON_CLX_TRUCK'}, + cargoes:[{skuNo:'SKU'}],parties:[],internalNotes:[],statusHistory:[]} + let nextId=201,nextKey=0,task=null + if(fault==='unowned-customer')report.steps[0].evidence.tenantId='21' + const base='/api/platform/v1/fba-pricing/carrier-rates/price-books' + Object.assign(r,{report,marker:'channel-fixture',merchantToken:'owner',platformToken:'platform',newIdempotencyKey:()=>`key-${++nextKey}`, + orderAcceptanceFixture:{masterData:{receiving:{id:'2'},channel:{priceBookVersionId:'100'}},payload:{channelCode:'MATSON_CLX_TRUCK',cargoes:[{skuNo:'SKU'}]}}, + apiStep:async(_module,name,request,validate)=>{ + calls.push({name,path:request.path,method:request.method,body:request.body,token:request.token,headers:request.headers}) + const url=new URL(request.path,'https://offline.invalid') + let data=null,bytes=null,code='200',message='success' + if(url.pathname.endsWith('/tenant/profile'))data={id:'20',status:'ACTIVE'} + else if(url.pathname===base && request.method!=='POST')data=url.searchParams.get('customerId')==='0'?structuredClone(publicVersions):[...books.values()].map(book=>structuredClone(book.version)) + else if(request.path===`${base}/100`)data=structuredClone(source.reference) + else if(request.path===`${base}/100/source`)bytes=source.bytes + else if(url.pathname===base && request.method==='POST') { + assert.equal(url.searchParams.get('customerId'),'20') + assert.equal(request.token,'platform') + const file=request.body.get('file'),raw=Buffer.from(await file.arrayBuffer()),entries=r.orderZipRead(raw) + const containsTarget=entries.get('xl/workbook.xml').includes('美森正班海卡') + const id=String(nextId++),channels=source.reference.channels.filter(channel=>containsTarget||channel.channelCode!=='MATSON_CLX_TRUCK') + data={version:{id,customerId:fault==='wrong-version-owner'?'0':'20',status:'PENDING_CONFIRMATION',sourceSha256:createHash('sha256').update(raw).digest('hex')}, + channels:structuredClone(channels),rates:structuredClone(source.reference.rates.filter(rate=>channels.some(channel=>channel.channelCode===rate.channelCode)))} + books.set(id,data) + } else if(url.pathname.startsWith(`${base}/`)) { + const id=url.pathname.slice(base.length+1).split('/')[0],book=books.get(id) + assert.ok(book,'only this run uploaded version may be read/published') + if(request.method==='POST') { + assert.ok(request.path.endsWith('/publish'));assert.equal(book.version.customerId,'20') + assert.deepEqual(plain(request.body),{originMappings:[{originWarehouseCode:'YIWU',warehouseId:'2'}],overseasMappings:[]}) + for(const previous of books.values())if(previous.version.status==='ACTIVE')previous.version.status='SUPERSEDED' + book.version.status='ACTIVE';book.version.blockingErrorCount=0 + if(fault==='public-drift')publicVersions[0].status='SUPERSEDED' + } + data=structuredClone(book) + } else if(url.pathname.endsWith('/pricing/channels')) { + const active=[...books.values()].find(book=>book.version.status==='ACTIVE') + let channels=active.channels + if(fault==='channel-retained'&&active.version.id==='202')channels=source.reference.channels + data=channels.map(channel=>({...channel,priceBookVersionId:active.version.id})) + } else if(url.pathname==='/api/merchant/v1/orders/first-leg'&&request.method==='POST')data='42' + else if(url.pathname==='/api/merchant/v1/orders/first-leg/42')data=structuredClone(order) + else if(url.pathname.includes('/tasks/by-order/'))data=structuredClone(task) + else if(url.pathname==='/api/platform/inbounds')data={page:1,total:0,records:[]} + else if(url.pathname.endsWith('/submit')) { + assert.equal(request.body.expectedVersion,7) + code='A0409';message=fault==='wrong-conflict'?'订单版本已变化,请刷新后重试':'所选渠道已下线,请重新选择' + if(fault==='order-drift')order.order.version++ + if(fault==='task-drift')task={id:'task',orderId:'42',tenantId:'20'} + } else throw new Error(`unhandled offline request ${request.path}`) + const response={status:200,json:{code,message,data},bytes};validate(response);return response + }, + }) + return {r,report,calls,books} +} + +test('channel gate publishes private A then B, proves client removal and rejects both original-version submits',async()=>{ + const {r,report,calls,books}=channelGateHarness() + await r.orderSubmissionChannelGateModule() + assert.equal(report.orderSubmissionGates.channel.status,'REJECTIONS_VERIFIED') + assert.equal(report.orderSubmissionGates.channel.publicVersionsUnchanged,true) + assert.deepEqual(report.orderSubmissionGates.remaining,['Other pending scope']) + assert.equal(books.get('201').version.status,'SUPERSEDED');assert.equal(books.get('202').version.status,'ACTIVE') + assert.equal(calls.filter(call=>call.path.endsWith('/publish')).length,2) + assert.equal(calls.filter(call=>call.path.includes('/submit')).length,2) + assert.ok(calls.filter(call=>call.method==='POST').every(call=>!call.path.includes('customerId=0')&&!call.path.includes('/100/'))) +}) + +for(const fault of ['unowned-customer','wrong-version-owner','channel-retained','wrong-conflict','order-drift','task-drift','public-drift'])test(`channel gate fails closed: ${fault}`,async()=>{ + const {r,calls}=channelGateHarness(fault) + await assert.rejects(r.orderSubmissionChannelGateModule()) + if(['unowned-customer','wrong-version-owner'].includes(fault))assert.equal(calls.some(call=>call.path.endsWith('/publish')),false) +}) + function balanceGateHarness(fault='') { const calls=[],ledger=[],report={resources:{tenantId:'20',orderIds:[]},steps:[{module:'registration',name:'enterprise self-registration',passed:true,evidence:{tenantId:'20'}}]} const wallet={currencyCode:'CNY',version:0,creditActive:false,actualBalance:0,availableBalance:0,frozenBalance:0, diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 878d9a83d..09ac593a4 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -16,7 +16,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { randomUUID, createHash } from 'node:crypto' import { resolve } from 'node:path' import { createInterface } from 'node:readline/promises' -import { inflateRawSync } from 'node:zlib' +import { inflateRawSync, deflateRawSync } 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') @@ -103,6 +103,7 @@ try { await orderSubmissionBalanceGateModule() await orderMutationEvidenceModule() await orderExcelImportModule() + await orderSubmissionChannelGateModule() await notificationInboxModule() await accountSecurityModule() report.success = report.steps.every((step) => step.passed) @@ -2102,7 +2103,7 @@ function orderZipRead(bytes) { 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')) + entries.set(name, /\.(xml|rels)$/i.test(name) ? content.toString('utf8') : Buffer.from(content)) offset += 46 + nameLength + input.readUInt16LE(offset + 30) + input.readUInt16LE(offset + 32) } return entries @@ -2112,20 +2113,23 @@ function orderCrc(bytes) { 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) { +function orderZipWrite(entries, compress = false) { 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 packed = compress ? deflateRawSync(data) : 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) + header.writeUInt16LE(compress ? 8 : 0, 8) + header.writeUInt32LE(crc, 14); header.writeUInt32LE(packed.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(compress ? 8 : 0, 10) + central.writeUInt32LE(crc, 16); central.writeUInt32LE(packed.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 + local.push(header, filename, packed); directory.push(central, filename) + offset += header.length + filename.length + packed.length } const central = Buffer.concat(directory), end = Buffer.alloc(22) end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries.size, 8); end.writeUInt16LE(entries.size, 10) @@ -2171,6 +2175,41 @@ function importRollbackRows(validFirst, validSecond) { const changed = [...validSecond]; changed[30] += '-changed' // SaveRequest.customerRemark participates in CREATE_DRAFT hash return { seed: [invalid, validSecond], conflict: [validFirst, changed], retry: [validFirst, validSecond] } } +function channelGateWorkbook(sourceBytes, reference, channelCode) { + const matches = reference.channels.filter(channel => channel.channelCode === channelCode) + assertEqual(matches.length, 1, 'one authoritative target channel') + const sourceSheetName = matches[0].sourceSheetName + assertPresent(sourceSheetName, 'target channel source sheet') + const removedCodes = reference.channels.filter(channel => channel.sourceSheetName === sourceSheetName).map(channel => channel.channelCode) + assertTrue(reference.channels.some(channel => !removedCodes.includes(channel.channelCode)), 'B must retain another real channel') + const entries = orderZipRead(sourceBytes), workbook = entries.get('xl/workbook.xml') + assertTrue(typeof workbook === 'string', 'source workbook XML') + let removed = 0 + const updated = workbook.replace(/]*\/>/g, element => { + const name = element.match(/\bname="([^"]*)"/)?.[1] + if (name !== undefined && orderXmlDecode(name) === sourceSheetName) { removed++; return '' } + return element + }) + assertEqual(removed, 1, 'remove exactly the authoritative channel sheet from workbook directory') + // Preserve all parts and relationships byte-for-byte. The SAX price importer enumerates only workbook-listed sheets. + entries.set('xl/workbook.xml', updated) + return { bytes: orderZipWrite(entries, true), sourceSheetName, removedCodes } +} +function channelGateMappings(detail, reference) { + const mapping = (kind, keyField, idField) => { + const keys = [...new Set(detail.rates.filter(rate => kind === 'origin' || rate.destinationType === 'OVERSEAS').map(rate => rate[keyField]))] + return keys.map(key => { + assertPresent(key, `price ${kind} source label`) + const ids = [...new Set(reference.rates.filter(rate => (kind === 'origin' || rate.destinationType === 'OVERSEAS') + && rate[keyField] === key).map(rate => String(rate[idField] ?? '')))] + assertTrue(ids.length === 1 && /^[1-9]\d*$/.test(ids[0]), `unique published ${kind} mapping for ${key}`) + return kind === 'origin' ? { originWarehouseCode: key, warehouseId: ids[0] } : { sourceDestinationName: key, warehouseId: ids[0] } + }) + } + assertTrue(Array.isArray(detail.rates) && detail.rates.length > 0, 'nonempty parsed prices required') + return { originMappings: mapping('origin','originWarehouseCode','originWarehouseId'), + overseasMappings: mapping('overseas','destinationName','destinationWarehouseId') } +} // END ORDER ACCEPTANCE HELPERS async function ownedOrderSnapshot(module, name, id, expected, validate = () => {}) { @@ -2464,6 +2503,136 @@ async function orderSubmissionBalanceGateModule() { } } +async function orderSubmissionChannelGateModule() { + const module='order-channel-gate', base='/api/platform/v1/fba-pricing/carrier-rates/price-books' + const customerId=String(report.resources.tenantId) + assertTrue(/^[1-9]\d*$/.test(customerId), 'customer-specific price book must never use platform scope zero') + assertTrue(report.steps.some(step=>step.module==='registration' && step.name==='enterprise self-registration' + && step.passed===true && String(step.evidence?.tenantId)===customerId), 'only current-run registered customer is eligible') + assertEqual(report.orderSubmissionGates?.balance?.restoration,'RESTORED_WITH_AUDIT','negative funds must already be restored') + const {masterData,payload}=orderAcceptanceFixture + const proof=report.orderSubmissionGates.channel={status:'PREPARING',customerId,channelCode:payload.channelCode,versions:[]} + await apiStep(module,'verify current customer before private pricing writes',{ + path:'/api/merchant/v1/iam/tenant/profile',token:merchantToken, + },({json})=>{requireSuccess(json);assertEqual(String(json.data?.id),customerId,'current customer');assertEqual(json.data.status,'ACTIVE','eligible customer')},()=>({customerId})) + const list=async(scope,label)=>{ + const response=await apiStep(module,label,{path:`${base}?customerId=${scope}`,token:platformToken},({json})=>{ + requireSuccess(json);assertTrue(Array.isArray(json.data),'price version list required') + assertTrue(json.data.every(version=>String(version.customerId)===String(scope)),'exact price-book customer scope') + },({json})=>({customerId:scope,versionCount:json.data.length,versionsSha256:orderDigest(json.data), + activeVersionIds:json.data.filter(version=>version.status==='ACTIVE').map(version=>version.id)})) + return response.json.data + } + const publicBefore=await list('0','freeze public price-book versions without changing them') + assertEqual((await list(customerId,'require no previous customer-specific price books')).length,0,'do not replace any existing customer private version') + const active=publicBefore.filter(version=>version.status==='ACTIVE' && String(version.id)===String(masterData.channel.priceBookVersionId)) + assertEqual(active.length,1,'selected route must originate from one active public source') + let reference + await apiStep(module,'read authoritative active price source and mappings',{ + path:`${base}/${active[0].id}`,token:platformToken, + },({json})=>{ + requireSuccess(json);reference=json.data + assertEqual(String(reference?.version?.customerId),'0','read-only source is public') + assertEqual(String(reference.version.id),String(active[0].id),'exact active source') + assertEqual(reference.version.status,'ACTIVE','valid reusable source') + assertTrue(Array.isArray(reference.channels)&&reference.channels.length>1,'source must retain channels after removing target sheet') + },({json})=>({versionId:json.data.version.id,sourceSha256:json.data.version.sourceSha256})) + let sourceBytes + await apiStep(module,'reuse existing valid XLSX source for private A',{ + path:`${base}/${reference.version.id}/source`,token:platformToken,responseType:'bytes', + },({status,bytes})=>{ + assertEqual(status,200,'source download HTTP');sourceBytes=bytes + assertEqual(createHash('sha256').update(bytes).digest('hex'),reference.version.sourceSha256,'source SHA-256 matches published version') + },({bytes})=>({sizeBytes:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex')})) + const replacement=channelGateWorkbook(sourceBytes,reference,payload.channelCode) + proof.sourceVersionId=reference.version.id;proof.sourceSheetName=replacement.sourceSheetName;proof.removedChannelCodes=replacement.removedCodes + const codesA=reference.channels.map(channel=>channel.channelCode).sort() + const codesB=codesA.filter(code=>!replacement.removedCodes.includes(code)) + const channelList=async(versionId,containsTarget,label)=>{ + const response=await apiStep(module,label,{ + path:`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${masterData.receiving.id}`,token:merchantToken, + },({json})=>{ + requireSuccess(json);assertTrue(Array.isArray(json.data),'client channel list must be a successful array') + assertEqual(json.data.some(channel=>channel.channelCode===payload.channelCode),containsTarget,'target channel visibility') + assertTrue(json.data.every(channel=>String(channel.priceBookVersionId)===String(versionId)),'client must read private version, never fall back to public') + },({json})=>({customerId,versionId,channelCodes:json.data.map(channel=>channel.channelCode)})) + return response.json.data + } + const publish=async(bytes,label,expectedCodes)=>{ + const sha256=createHash('sha256').update(bytes).digest('hex'), form=new FormData() + form.append('file',new Blob([bytes],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}),`gate-${marker}-${label}.xlsx`) + let parsed + await apiStep(module,`upload customer-only version ${label}`,{ + method:'POST',path:`${base}?customerId=${customerId}`,token:platformToken,body:form, + },({json})=>{ + requireSuccess(json);parsed=json.data + assertEqual(String(parsed?.version?.customerId),customerId,'uploaded version exclusively belongs to new customer') + assertPresent(parsed.version.id,'uploaded version identity');assertEqual(parsed.version.status,'PENDING_CONFIRMATION','upload stays pending') + assertEqual(parsed.version.sourceSha256,sha256,'uploaded source digest') + assertOrderUnchanged(expectedCodes,[...parsed.channels.map(channel=>channel.channelCode)].sort()) + },({json})=>({customerId,versionId:json.data?.version?.id,sourceSha256:sha256})) + const entry={label,id:String(parsed.version.id),customerId,sourceSha256:sha256,status:'PENDING_CONFIRMATION'} + proof.versions.push(entry) + const mappings=channelGateMappings(parsed,reference) + await apiStep(module,`recheck exact ownership immediately before publishing ${label}`,{ + path:`${base}/${entry.id}`,token:platformToken, + },({json})=>{ + requireSuccess(json);assertEqual(String(json.data?.version?.customerId),customerId,'publication ownership') + assertEqual(String(json.data.version.id),entry.id,'publication version');assertEqual(json.data.version.sourceSha256,sha256,'publication source') + assertEqual(json.data.version.status,'PENDING_CONFIRMATION','publication precondition') + },()=>({customerId,versionId:entry.id})) + await apiStep(module,`publish private ${label} with source-verified warehouse mappings`,{ + method:'POST',path:`${base}/${entry.id}/publish`,token:platformToken,body:mappings, + },({json})=>{ + requireSuccess(json);assertEqual(String(json.data?.version?.customerId),customerId,'published scope') + assertEqual(String(json.data.version.id),entry.id,'published identity');assertEqual(json.data.version.status,'ACTIVE','published status') + assertEqual(Number(json.data.version.blockingErrorCount),0,'no ignored publication blockers') + assertOrderUnchanged(expectedCodes,[...json.data.channels.map(channel=>channel.channelCode)].sort()) + assertTrue(Array.isArray(json.data.rates)&&json.data.rates.length>0,'remaining real rates') + },()=>({customerId,versionId:entry.id,mappings})) + entry.status='ACTIVE' + return entry.id + } + try { + const a=await publish(sourceBytes,'A',codesA) + proof.clientChannelsA=await channelList(a,true,'client sees target channel in private A') + const gatePayload={...structuredClone(payload),customerRemark:`CHANNEL-GATE-${marker}`} + const created=await apiStep(module,'create fresh draft while target channel is available in A',{ + method:'POST',path:'/api/merchant/v1/orders/first-leg',token:merchantToken,body:gatePayload, + headers:{'Idempotency-Key':newIdempotencyKey('channel-gate-create')}, + },({json})=>{requireSuccess(json);assertPresent(json.data,'channel-gate draft')},({json})=>({orderId:json.data})) + const orderId=created.json.data;proof.orderId=orderId;report.resources.orderIds.push(orderId) + const before=await ownedOrderSnapshot(module,'freeze original A draft version and full facts',orderId) + assertEqual(before.order.status,'DRAFT','unfulfilled channel gate fixture') + assertEqual(before.order.channelCode,payload.channelCode,'original draft channel') + const fulfillment=await submissionGateFulfillmentSnapshot(before.order,'before private channel removal') + const commands=Object.fromEntries(['merchant','platform'].map(role=>[role,orderMutationCommand(before,{},newIdempotencyKey(`removed-channel-${role}`))])) + proof.commands=commands + const b=await publish(replacement.bytes,'B',codesB) + proof.clientChannelsB=await channelList(b,false,'client target channel is removed in private B') + const own=await list(customerId,'verify A superseded only within this customer and B active') + assertTrue(own.length===2 && own.some(version=>String(version.id)===a&&version.status==='SUPERSEDED') + && own.some(version=>String(version.id)===b&&version.status==='ACTIVE'),'exact customer A to B replacement') + for(const role of ['merchant','platform']) { + await apiStep(module,`${role} original draft rejects now-unavailable channel`,{ + method:'POST',path:`/api/${role}/v1/orders/first-leg/${orderId}/submit${role==='platform'?`?tenantId=${customerId}`:''}`, + token:role==='merchant'?merchantToken:platformToken,...commands[role], + },({json,status})=>{ + assertEqual(status,200,'submit HTTP');assertEqual(String(json?.code),'A0409','unavailable channel conflict') + assertEqual(json?.message,'所选渠道已下线,请重新选择','channel-specific rejection, not version or qualification conflict') + },()=>({customerId,orderId,expectedVersion:commands[role].body.expectedVersion,channelCode:payload.channelCode})) + await ownedOrderSnapshot(module,`${role} channel rejection leaves original order unchanged`,orderId,before) + assertOrderUnchanged(fulfillment,await submissionGateFulfillmentSnapshot(before.order,`${role} channel rejection`)) + } + proof.status='SUBMISSIONS_REJECTED' + } finally { + assertOrderUnchanged(publicBefore,await list('0','verify every public price-book version unchanged')) + proof.publicVersionsUnchanged=true + } + proof.status='REJECTIONS_VERIFIED' + report.orderSubmissionGates.remaining=report.orderSubmissionGates.remaining.filter(item=>!item.startsWith('Channel-offline requires')) +} + async function importOrderSet(name, expectedIds) { const ids = [], seen = new Set() let total = null