2026-08-08 10:11:04 +08:00
#!/usr/bin/env node
import { mkdir , readFile , writeFile } from 'node:fs/promises'
import { dirname , resolve } from 'node:path'
2026-08-12 10:59:39 +08:00
import { pathToFileURL } from 'node:url'
2026-08-08 10:11:04 +08:00
import process from 'node:process'
2026-10-22 14:37:59 +08:00
const CONFIGURED_MODULES = [ 'merchant-qualification-fixture' , 'customers' , 'overseas-storage-fees' , 'system' , 'notifications' , 'pda-releases' , 'audit' , 'dashboard' , 'self-operated-analytics' , 'master-data' , 'suppliers' , 'domestic-container' , 'orders' , 'inbound-hold' , 'fee-items' , 'pricing' , 'downloads' , 'inbound-weight-ocr' , 'inbound-measurement' , 'inbound-fees' , 'phase3-container-loading' , 'phase3-container-overview' , 'phase3-loading' , 'phase3-loading-model-bridge' , 'phase3-pda-convergence' , 'phase3-evidence-gate' , 'supplemental' , 'supplemental-boundaries' , 'ocean-bookings' , 'truck-appointments' , 'service-center-flow' , 'phase3-loading-finalize' , 'phase3-container-documents' , 'phase3-customs' , 'phase3-container-customs' , 'phase3-port-schedules' , 'sailing-calendar' , 'phase3-carrier-schedule-queries' , 'phase3-outbound-dispatch' , 'phase3-outbound-aggregate' , 'phase3-dispatch-gate' , 'phase3-waiting-for-loading' , 'phase3-warehouse-rbac' , 'phase3-order-splitting' , 'manual-state-matrix' , 'phase4-overseas-warehouse' , 'phase4-carrier-pickup-ready' , 'appointment-work' , 'phase4-tail-order-controls' , 'phase4-overseas-pda' , 'phase4-overseas-pda-operations' , 'phase4-overseas-return-receipt' , 'phase4-overseas-return-followups' , 'phase4-overseas-inbound-operations' , 'phase4-overseas-client-services' , 'phase4-overseas-dispatch' , 'phase5-overseas-fulfillment' , 'phase5-terminal-drayage' , 'phase4-cross-region-events' , 'phase4-overseas-business' , 'phase4-overseas-master-data' , 'fleet-management' , 'phase4-carrier-nodes' , 'finance-full-fixtures' , 'finance-reconciliation-full' , 'finance-settlement-full' , 'finance-direct-paid-isolation' , 'finance-downloads' , 'firstleg-container-fee-allocation' , 'phase4-overseas-finance-cross-region' , 'phase4-overseas-wallet-and-pallet-cost' , 'customer-operating-statistics' ]
const OPTIONAL_MODULES = [ 'phase5-root-fulfillment' , 'manual-order-splitting-fixture' , 'manual-order-splitting-verify' ,
2026-08-26 16:50:22 +08:00
'manual-preload-push-fixture' , 'manual-preload-push-verify' ,
'manual-pda-ready-fixture' , 'manual-pda-ready-verify' ,
'manual-pda-batch-fixture' , 'manual-pda-batch-verify' ,
2026-08-29 11:10:28 +08:00
'manual-loading-complete-fixture' , 'manual-loading-complete-verify' ,
2026-10-19 16:41:20 +08:00
'coload-groups' , 'dropship-pallet-matrix' , 'dropship-mixed-palletization' , 'dropship-physical-carton-chain' , 'dropship-browser-fixture' , 'mixed-container-receiving' , 'pda-loading-link-regression' , 'fleet-vehicle-care' , 'fleet-driver-qualification' ]
2026-08-26 16:50:22 +08:00
const AVAILABLE_MODULES = new Set ([... CONFIGURED_MODULES , ... OPTIONAL_MODULES ])
2026-10-22 14:37:59 +08:00
const FIXED_RESOURCE_SKIPPED_MODULES = new Set ([ 'customers' , 'supplemental-boundaries' , 'system' , 'master-data' ,
'notifications' , 'pricing' , 'phase4-overseas-master-data' ])
2026-09-01 10:18:19 +08:00
const A0_LOCATION_PAGE_SIZE = 200
const A0_MAX_LOCATION_MUTATIONS = 8
2026-10-08 19:09:30 +08:00
export function transportFailureDetails ( error ) {
const causes = []
const visited = new Set ()
const symbol = value => typeof value === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,79}$/ . test ( value ) ? value : null
function visit ( value ) {
if ( ! value || typeof value !== 'object' || visited . has ( value ) || causes . length >= 6 ) return
visited . add ( value )
causes . push ({ name : symbol ( value . name ), code : symbol ( value . code ), syscall : symbol ( value . syscall ) })
visit ( value . cause )
if ( Array . isArray ( value . errors )) for ( const nested of value . errors ) visit ( nested )
}
visit ( error )
return { causes }
}
2026-09-01 10:18:19 +08:00
export function planA0LocationIsolation ( locations , requiredSlots = 1 ,
maxMutations = A0_MAX_LOCATION_MUTATIONS ) {
const slots = Math . max ( 1 , Number . isInteger ( Number ( requiredSlots )) ? Number ( requiredSlots ) : 1 )
const limit = Math . max ( 0 , Number . isInteger ( Number ( maxMutations )) ? Number ( maxMutations ) : 0 )
const unique = new Map ()
for ( const location of Array . isArray ( locations ) ? locations : []) {
const locationId = location ? . id == null ? '' : String ( location . id )
if ( ! locationId || location ? . sharedOverflow === true || unique . has ( locationId )) continue
unique . set ( locationId , location )
}
const normal = [... unique . values ()]. sort (( left , right ) =>
String ( left ? . locationCode || '' ). localeCompare ( String ( right ? . locationCode || '' ))
|| String ( left . id ). localeCompare ( String ( right . id )))
const remainingNormal = Math . max ( 0 , slots - 1 )
const mutationsRequired = Math . max ( 0 , normal . length - remainingNormal )
return {
enabled : mutationsRequired <= limit ,
requiredSlots : slots ,
normalCount : normal . length ,
expectedRemainingNormal : Math . min ( normal . length , remainingNormal ),
mutationsRequired ,
targets : mutationsRequired <= limit ? normal . slice ( 0 , mutationsRequired ) : [],
}
}
2026-08-13 20:00:30 +08:00
2026-08-24 21:00:22 +08:00
const MODULE_DEPENDENCIES = new Map ([
2026-10-21 21:30:24 +08:00
[ 'customers' , [ 'merchant-qualification-fixture' ]],
[ 'overseas-storage-fees' , [ 'merchant-qualification-fixture' ]],
2026-10-21 15:53:33 +08:00
[ 'inbound-weight-ocr' , [ 'orders' ]],
[ 'orders' , [ 'merchant-qualification-fixture' ]],
2026-09-30 15:16:39 +08:00
[ 'phase5-root-fulfillment' , [ 'merchant-qualification-fixture' ]],
2026-09-16 15:21:08 +08:00
[ 'pda-loading-link-regression' , [ 'merchant-qualification-fixture' ]],
2026-09-15 16:10:55 +08:00
[ 'phase4-overseas-return-followups' , [ 'phase4-overseas-return-receipt' ]],
2026-09-14 18:34:37 +08:00
[ 'dropship-pallet-matrix' , [ 'merchant-qualification-fixture' ]],
2026-10-19 16:41:20 +08:00
[ 'dropship-mixed-palletization' , [ 'merchant-qualification-fixture' ]],
2026-09-16 21:22:18 +08:00
[ 'dropship-physical-carton-chain' , [ 'merchant-qualification-fixture' ]],
2026-09-15 13:36:58 +08:00
[ 'dropship-browser-fixture' , [ 'merchant-qualification-fixture' ]],
2026-09-15 14:18:18 +08:00
[ 'mixed-container-receiving' , [ 'merchant-qualification-fixture' ]],
2026-08-26 11:10:42 +08:00
[ 'suppliers' , [ 'merchant-qualification-fixture' ]],
[ 'supplemental' , [ 'merchant-qualification-fixture' ]],
2026-08-24 21:00:22 +08:00
[ 'orders' , [ 'merchant-qualification-fixture' ]],
2026-09-07 11:20:10 +08:00
[ 'inbound-hold' , [ 'orders' ]],
2026-08-24 21:00:22 +08:00
[ 'inbound-measurement' , [ 'orders' ]],
2026-09-09 21:13:10 +08:00
[ 'inbound-fees' , [ 'inbound-measurement' ]],
2026-08-26 16:50:22 +08:00
[ 'manual-order-splitting-fixture' , [ 'inbound-measurement' ]],
2026-08-29 11:10:28 +08:00
[ 'coload-groups' , [ 'inbound-measurement' ]],
2026-08-24 21:00:22 +08:00
[ 'phase3-container-loading' , [ 'inbound-measurement' ]],
[ 'phase3-loading' , [ 'phase3-container-loading' ]],
[ 'phase3-container-overview' , [ 'phase3-loading' ]],
[ 'phase3-loading-model-bridge' , [ 'phase3-loading' ]],
[ 'phase3-pda-convergence' , [ 'phase3-loading' ]],
[ 'phase3-evidence-gate' , [ 'phase3-pda-convergence' ]],
[ 'ocean-bookings' , [ 'phase3-loading' ]],
[ 'truck-appointments' , [ 'merchant-qualification-fixture' ]],
[ 'service-center-flow' , [ 'ocean-bookings' , 'truck-appointments' ]],
[ 'phase3-loading-finalize' , [ 'phase3-loading' , 'phase3-evidence-gate' ]],
2026-09-08 23:56:54 +08:00
[ 'phase3-container-documents' , [ 'phase3-outbound-dispatch' ]],
2026-08-24 21:00:22 +08:00
[ 'phase3-customs' , [ 'phase3-loading-finalize' ]],
2026-08-28 22:39:18 +08:00
[ 'phase3-container-customs' , [ 'merchant-qualification-fixture' ]],
2026-08-24 21:00:22 +08:00
[ 'phase3-port-schedules' , [ 'ocean-bookings' ]],
2026-09-08 23:56:54 +08:00
[ 'phase3-outbound-dispatch' , [ 'phase3-customs' , 'phase3-port-schedules' , 'service-center-flow' ]],
2026-08-24 21:00:22 +08:00
[ 'phase3-outbound-aggregate' , [ 'phase3-outbound-dispatch' ]],
[ 'phase3-dispatch-gate' , [ 'phase3-outbound-dispatch' ]],
[ 'phase3-waiting-for-loading' , [ 'phase3-outbound-dispatch' ]],
[ 'phase3-carrier-schedule-queries' , [ 'phase3-outbound-dispatch' ]],
[ 'phase3-order-splitting' , [ 'phase3-loading-finalize' ]],
2026-10-03 06:26:33 +08:00
[ 'manual-state-matrix' , [ 'phase3-loading-finalize' , 'phase3-customs' , 'ocean-bookings' , 'phase3-container-documents' , 'phase5-overseas-fulfillment' ]],
2026-09-09 02:54:09 +08:00
[ 'phase4-carrier-pickup-ready' , [ 'phase3-carrier-schedule-queries' , 'phase3-container-documents' ]],
2026-09-08 14:07:31 +08:00
[ 'phase4-tail-order-controls' , [ 'phase4-carrier-pickup-ready' ]],
2026-10-20 14:22:53 +08:00
[ 'appointment-work' , [ 'phase4-carrier-pickup-ready' ]],
2026-09-08 14:07:31 +08:00
[ 'phase4-overseas-pda' , [ 'phase4-tail-order-controls' ]],
2026-08-25 11:25:13 +08:00
[ 'phase4-overseas-pda-operations' , [ 'phase4-overseas-pda' ]],
2026-09-11 16:02:10 +08:00
[ 'phase4-overseas-return-receipt' , [ 'merchant-qualification-fixture' ]],
2026-08-25 11:25:13 +08:00
[ 'phase4-overseas-inbound-operations' , [ 'phase4-overseas-pda-operations' ]],
[ 'phase4-overseas-client-services' , [ 'phase4-overseas-pda-operations' ]],
2026-10-22 15:41:50 +08:00
[ 'phase4-overseas-dispatch' , [ 'phase4-overseas-pda-operations' , 'phase4-overseas-client-services' ]],
2026-10-22 14:37:59 +08:00
[ 'phase5-overseas-fulfillment' , [ 'phase4-overseas-dispatch' , 'phase4-overseas-business' ]],
2026-09-08 14:07:31 +08:00
[ 'phase5-terminal-drayage' , [ 'phase4-overseas-pda' ]],
2026-08-25 11:25:13 +08:00
[ 'phase4-cross-region-events' , [ 'system' , 'phase3-customs' , 'phase4-overseas-pda-operations' ]],
2026-09-08 14:07:31 +08:00
[ 'phase4-carrier-nodes' , [ 'phase4-overseas-pda-operations' , 'phase5-terminal-drayage' ]],
2026-09-02 19:29:23 +08:00
[ 'phase4-overseas-business' , [ 'merchant-qualification-fixture' ]],
[ 'firstleg-container-fee-allocation' , []],
2026-09-04 17:17:08 +08:00
[ 'finance-direct-paid-isolation' , [ 'finance-full-fixtures' , 'finance-reconciliation-full' , 'finance-settlement-full' ]],
2026-09-02 19:29:23 +08:00
[ 'phase4-overseas-finance-cross-region' , [ 'phase4-overseas-business' , 'phase4-overseas-pda-operations' ]],
2026-09-06 11:00:06 +08:00
[ 'phase4-overseas-wallet-and-pallet-cost' , [ 'phase4-overseas-business' , 'phase4-overseas-pda-operations' ]],
2026-08-24 21:00:22 +08:00
])
function stableTopologicalOrder ( modules ) {
const selected = new Set ( modules )
const pending = [... modules ]
const result = []
const completed = new Set ()
while ( pending . length > 0 ) {
const readyIndex = pending . findIndex (( module ) => ( MODULE_DEPENDENCIES . get ( module ) || [])
. every (( dependency ) => ! selected . has ( dependency ) || completed . has ( dependency )))
if ( readyIndex < 0 ) throw new Error ( `HTTP 验收模块依赖存在循环: ${ pending . join ( ',' ) } ` )
const [ module ] = pending . splice ( readyIndex , 1 )
result . push ( module )
completed . add ( module )
}
return result
}
2026-08-24 21:46:19 +08:00
function dependencyClosure ( module , visiting = new Set ()) {
if ( visiting . has ( module )) throw new Error ( `HTTP 验收模块依赖存在循环: ${ [... visiting , module ]. join ( ',' ) } ` )
const next = new Set ( visiting ). add ( module )
const result = []
for ( const dependency of MODULE_DEPENDENCIES . get ( module ) || []) {
for ( const transitive of dependencyClosure ( dependency , next )) {
if ( ! result . includes ( transitive )) result . push ( transitive )
}
if ( ! result . includes ( dependency )) result . push ( dependency )
}
return result
}
2026-08-13 20:00:30 +08:00
export function normalizeModuleOrder ( modules ) {
const normalized = [... modules ]
2026-08-14 23:14:11 +08:00
const manualGroups = [ 'order-inbound-status-fixtures' , 'manual-state-loading-pda' , 'manual-state-ocean-trucking' ]
const firstManualGroupIndex = normalized . findIndex (( name ) => manualGroups . includes ( name ))
if ( ! normalized . includes ( 'manual-state-matrix' ) && firstManualGroupIndex >= 0 ) {
normalized . splice ( firstManualGroupIndex , 0 , 'manual-state-matrix' )
}
2026-08-14 12:50:37 +08:00
const manualMatrixIndex = normalized . indexOf ( 'manual-state-matrix' )
if ( manualMatrixIndex >= 0 ) {
2026-08-14 23:14:11 +08:00
for ( const group of manualGroups ) {
const internalGroupIndex = normalized . indexOf ( group )
if ( internalGroupIndex >= 0 ) normalized . splice ( internalGroupIndex , 1 )
}
2026-08-14 12:50:37 +08:00
const prerequisites = [
'merchant-qualification-fixture' ,
'phase3-container-loading' ,
'phase3-loading' ,
2026-08-14 23:14:11 +08:00
'ocean-bookings' ,
2026-08-14 12:50:37 +08:00
'phase3-loading-finalize' ,
2026-08-24 12:52:23 +08:00
'phase3-customs' ,
2026-08-14 12:50:37 +08:00
]
for ( const prerequisite of prerequisites ) {
if ( ! normalized . includes ( prerequisite )) {
normalized . splice ( normalized . indexOf ( 'manual-state-matrix' ), 0 , prerequisite )
}
}
}
2026-08-14 23:14:11 +08:00
if ( normalized . includes ( 'phase3-order-splitting' )) {
2026-08-24 14:34:01 +08:00
const prerequisites = [
'merchant-qualification-fixture' ,
'phase3-container-loading' ,
'phase3-loading' ,
'phase3-loading-finalize' ,
]
2026-08-14 23:14:11 +08:00
for ( const prerequisite of prerequisites ) {
if ( ! normalized . includes ( prerequisite )) {
normalized . splice ( normalized . indexOf ( 'phase3-order-splitting' ), 0 , prerequisite )
}
}
const splitIndex = normalized . indexOf ( 'phase3-order-splitting' )
2026-08-24 14:34:01 +08:00
const finalizeIndex = normalized . indexOf ( 'phase3-loading-finalize' )
if ( splitIndex < finalizeIndex ) {
2026-08-14 23:14:11 +08:00
normalized . splice ( splitIndex , 1 )
2026-08-24 14:34:01 +08:00
normalized . splice ( normalized . indexOf ( 'phase3-loading-finalize' ) + 1 , 0 , 'phase3-order-splitting' )
2026-08-14 23:14:11 +08:00
}
}
2026-08-24 15:07:17 +08:00
if ( normalized . includes ( 'phase4-overseas-business' )
&& ! normalized . includes ( 'merchant-qualification-fixture' )) {
normalized . splice ( normalized . indexOf ( 'phase4-overseas-business' ), 0 , 'merchant-qualification-fixture' )
}
2026-08-25 00:24:43 +08:00
for ( const consumer of [ 'phase4-overseas-inbound-operations' , 'phase4-overseas-client-services' ,
'phase4-overseas-dispatch' ]) {
2026-08-25 11:25:13 +08:00
if ( normalized . includes ( consumer ) && ! normalized . includes ( 'phase4-overseas-pda-operations' )) {
normalized . splice ( normalized . indexOf ( consumer ), 0 , 'phase4-overseas-pda-operations' )
2026-08-25 00:24:43 +08:00
}
}
2026-08-25 11:25:13 +08:00
if ( normalized . includes ( 'phase4-overseas-pda' )
&& ! normalized . includes ( 'phase4-overseas-pda-operations' )) {
normalized . splice ( normalized . indexOf ( 'phase4-overseas-pda' ) + 1 , 0 , 'phase4-overseas-pda-operations' )
}
2026-08-24 19:58:35 +08:00
if ( normalized . includes ( 'phase4-overseas-pda' )) {
const prerequisites = [ 'merchant-qualification-fixture' , 'phase3-loading' , 'phase3-pda-convergence' ,
'phase3-evidence-gate' , 'phase3-loading-finalize' ]
const targetIndex = normalized . indexOf ( 'phase4-overseas-pda' )
const missing = prerequisites . filter (( name ) => ! normalized . includes ( name ))
if ( missing . length > 0 ) normalized . splice ( targetIndex , 0 , ... missing )
}
2026-08-24 19:40:46 +08:00
if ( normalized . includes ( 'phase4-cross-region-events' )) {
const prerequisites = [ 'system' , 'merchant-qualification-fixture' , 'orders' , 'inbound-measurement' ,
'phase3-container-loading' , 'phase3-loading' , 'phase3-pda-convergence' , 'phase3-evidence-gate' ,
2026-08-25 11:25:13 +08:00
'phase3-loading-finalize' , 'phase3-customs' , 'phase4-overseas-pda-operations' ]
2026-08-24 21:00:22 +08:00
const targetIndex = normalized . indexOf ( 'phase4-cross-region-events' )
const missing = prerequisites . filter (( name ) => ! normalized . includes ( name ))
if ( missing . length > 0 ) normalized . splice ( targetIndex , 0 , ... missing )
2026-08-24 19:40:46 +08:00
}
2026-09-04 16:20:09 +08:00
const financeChain = [ 'finance-full-fixtures' , 'finance-reconciliation-full' ,
2026-09-04 17:17:08 +08:00
'finance-settlement-full' , 'finance-direct-paid-isolation' ]
2026-08-15 17:58:15 +08:00
const firstFinanceIndex = normalized . findIndex (( name ) => financeChain . includes ( name ))
if ( firstFinanceIndex >= 0 ) {
const prerequisites = [ 'merchant-qualification-fixture' , 'orders' , 'inbound-measurement' , 'phase3-container-loading' ]
const missing = prerequisites . filter (( name ) => ! normalized . includes ( name ))
if ( missing . length > 0 ) normalized . splice ( firstFinanceIndex , 0 , ... missing )
}
2026-08-17 18:00:38 +08:00
const financeDownloadsIndex = normalized . indexOf ( 'finance-downloads' )
if ( financeDownloadsIndex >= 0 && financeChain . some (( name ) => normalized . includes ( name ))) {
for ( const prerequisite of financeChain ) {
if ( ! normalized . includes ( prerequisite )) normalized . splice ( normalized . indexOf ( 'finance-downloads' ), 0 , prerequisite )
}
2026-08-17 18:04:04 +08:00
const completeFinanceChain = [... financeChain , 'finance-downloads' ]
const firstChainIndex = normalized . findIndex (( name ) => completeFinanceChain . includes ( name ))
let predecessor = firstChainIndex > 0 ? normalized [ firstChainIndex - 1 ] : null
if ( ! predecessor ) predecessor = [ 'phase3-container-loading' , 'inbound-measurement' , 'orders' , 'merchant-qualification-fixture' ]
. find (( name ) => normalized . includes ( name )) || null
const withoutChain = normalized . filter (( name ) => ! completeFinanceChain . includes ( name ))
const insertionIndex = predecessor ? withoutChain . indexOf ( predecessor ) + 1 : withoutChain . length
withoutChain . splice ( insertionIndex , 0 , ... completeFinanceChain )
normalized . splice ( 0 , normalized . length , ... withoutChain )
2026-08-17 18:00:38 +08:00
}
2026-08-14 10:09:46 +08:00
if ( normalized . includes ( 'fee-items' )) {
const prerequisites = [ 'merchant-qualification-fixture' , 'orders' ]
const feeItemsIndex = normalized . indexOf ( 'fee-items' )
const ordered = prerequisites . every (( name , index ) => {
const prerequisiteIndex = normalized . indexOf ( name )
const previousIndex = index === 0 ? - 1 : normalized . indexOf ( prerequisites [ index - 1 ])
return prerequisiteIndex > previousIndex && prerequisiteIndex < feeItemsIndex
})
if ( ! ordered ) {
const requested = normalized . filter (( name ) => ! prerequisites . includes ( name ))
requested . splice ( requested . indexOf ( 'fee-items' ), 0 , ... prerequisites )
normalized . splice ( 0 , normalized . length , ... requested )
}
}
2026-08-17 19:13:08 +08:00
if ( normalized . includes ( 'inbound-fees' )) {
const prerequisites = [ 'merchant-qualification-fixture' , 'orders' , 'inbound-measurement' ]
const targetIndex = normalized . indexOf ( 'inbound-fees' )
const missing = prerequisites . filter (( name ) => ! normalized . includes ( name ))
if ( missing . length > 0 ) normalized . splice ( targetIndex , 0 , ... missing )
}
2026-08-24 12:52:23 +08:00
const customsIndex = normalized . indexOf ( 'phase3-customs' )
const finalizeIndex = normalized . indexOf ( 'phase3-loading-finalize' )
if ( customsIndex >= 0 && finalizeIndex >= 0 && customsIndex < finalizeIndex ) {
normalized . splice ( finalizeIndex , 1 )
normalized . splice ( normalized . indexOf ( 'phase3-customs' ), 0 , 'phase3-loading-finalize' )
}
2026-08-13 20:00:30 +08:00
const loadingIndex = normalized . indexOf ( 'phase3-loading' )
const overviewIndex = normalized . indexOf ( 'phase3-container-overview' )
2026-08-24 21:00:22 +08:00
if ( loadingIndex >= 0 && overviewIndex >= 0 && overviewIndex < loadingIndex ) {
normalized . splice ( overviewIndex , 1 )
normalized . splice ( normalized . indexOf ( 'phase3-loading' ) + 1 , 0 , 'phase3-container-overview' )
}
2026-08-24 21:46:19 +08:00
for ( const module of [... normalized ]) {
const missing = dependencyClosure ( module ). filter (( dependency ) => ! normalized . includes ( dependency ))
if ( missing . length > 0 ) normalized . splice ( normalized . indexOf ( module ), 0 , ... missing )
}
2026-09-02 19:29:23 +08:00
return stableTopologicalOrder ([... new Set ( normalized )])
2026-08-13 20:00:30 +08:00
}
const DEFAULT_MODULES = normalizeModuleOrder ( CONFIGURED_MODULES )
2026-10-21 13:22:35 +08:00
/** 从执行注册表返回范围,供离线总览使用,避免维护第二套模块清单。 */
export function describeAcceptanceScope () {
return {
defaultModules : [... DEFAULT_MODULES ],
optionalModules : OPTIONAL_MODULES . filter (( name ) => ! DEFAULT_MODULES . includes ( name )),
2026-10-22 14:37:59 +08:00
fixedResourceSkippedModules : [... FIXED_RESOURCE_SKIPPED_MODULES ],
2026-10-21 13:22:35 +08:00
}
}
2026-08-24 19:58:35 +08:00
const WEIGHT_IMAGE_PATH = process . env . ADMIN_TEST_WEIGHT_IMAGE_PATH || ''
2026-08-08 10:11:04 +08:00
const CUSTOMER_FIELDS = [
'id' , 'tenantCode' , 'tenantName' , 'status' , 'legalName' , 'legalRepresentative' ,
'contactName' , 'contactPhone' , 'contactEmail' , 'contactWechat' ,
'servicePackage' , 'salesOwnerName' , 'paymentTermDays' , 'enterpriseType' , 'establishedDate' ,
'registeredCapital' , 'registeredCapitalCurrency' , 'registeredAddress' , 'internalRemark' ,
'createdAt' ,
]
const BOX_DEFAULTS = [
{ boxType : 'SMALL' , dimensions : [ '30.000' , '20.000' , '20.000' ], remark : '标准小箱' },
{ boxType : 'MEDIUM' , dimensions : [ '40.000' , '30.000' , '30.000' ], remark : '标准中箱' },
{ boxType : 'LARGE' , dimensions : [ '50.000' , '40.000' , '40.000' ], remark : '标准大箱' },
{ boxType : 'EXTRA_LARGE' , dimensions : [ '60.000' , '50.000' , '50.000' ], remark : '特大箱' },
]
function usage () {
console . log ( `后台管理测试环境 HTTP API 验收
用法:
node scripts/admin-test-env-acceptance.mjs [options]
选项:
2026-08-24 19:58:35 +08:00
--gateway <url> 必填,国内 Gateway 地址
--username <name> 必填,管理员账号
--password <value> 必填,管理员密码
2026-09-24 04:38:38 +08:00
--pda-username <name> 国内装柜链必填,实际领取任务的 PDA 作业员账号
--pda-password <value> 国内装柜链必填,PDA 作业员密码
2026-10-08 17:07:57 +08:00
--environment <name> 默认 test; gray 仅允许显式 FBA 定向闭环
2026-08-08 10:11:04 +08:00
--modules <names> 逗号分隔,默认 ${ DEFAULT_MODULES . join ( ',' ) }
2026-10-08 17:07:57 +08:00
--f40-tax-mode <mode> F40 税制:BUY_DECLARATION 或 TAX_REFUND
2026-08-26 11:10:42 +08:00
--customer-name <name> 指定本轮隔离客户名称,默认自动生成
--merchant-username <name> 复用既有已审核客户账号,不再创建客户
--merchant-password <value> 复用客户账号密码,不写入报告
--origin-warehouse-name <name> 固定国内起运仓名称
2026-10-09 17:21:56 +08:00
--prepare-domestic-pda-h5 true 仅准备新待拣任务供H5,不执行完整履约验收
2026-08-26 11:10:42 +08:00
--channel-name <name> 固定物流渠道名称
--channel-code <code> 固定物流渠道编码
--order-profile-mode <mode> 订单档案模式:fixed 或 randomized,默认fixed
--wait-split-count <n> 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1
2026-08-27 09:21:01 +08:00
--wait-split-package-count <n> 单票指定总箱数,1-1000;省略时使用既有轮换
--wait-split-sequence <n> 单票全局序号,正整数,默认1
--wait-split-random-value <n> 单票随机选择值,正整数,默认使用全局序号
2026-09-11 16:17:16 +08:00
--prepare-dropship-manual-count <true> 一件代发停在PDA清点前,不领取、不清点、不归托
2026-09-04 12:48:55 +08:00
--verify-pallet-location-guide <true> 验证整票库位块、图片槽绑定与删除后 RESERVED_EMPTY
2026-08-27 09:21:01 +08:00
--route-mode <mode> 线路选择:fixed 或 random,默认fixed
2026-08-26 16:50:22 +08:00
--manual-split-fixture-report <path> 人工分柜准备报告路径,仅只读核验模块使用
--manual-split-plan-id <id> 人工操作产生的智能装柜计划 ID,仅只读核验模块使用
--manual-preload-source-report <path> 人工推送预装前置或核验来源报告路径
--manual-pda-source-report <path> 人工订舱关联、预装确认前置或核验来源报告路径
--manual-pda-batch-source-report <path> PDA 人工领取、扫码批量提交前置或核验来源报告路径
--manual-loading-complete-source-report <path> 五类证据与 PDA 最终完成前置或核验来源报告路径
2026-08-08 10:11:04 +08:00
--output <path> JSON 报告路径,默认 reports/
--help 显示帮助
2026-09-29 19:48:34 +08:00
也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
2026-08-26 11:10:42 +08:00
ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME,
2026-10-08 17:07:57 +08:00
MERCHANT_TEST_PASSWORD, ADMIN_PDA_USERNAME, ADMIN_PDA_PASSWORD, ADMIN_WAIT_SPLIT_COUNT。
gray 使用独立 ADMIN_GRAY_GATEWAY/USERNAME/PASSWORD、ADMIN_GRAY_PDA_USERNAME/PASSWORD、
ADMIN_GRAY_CUSTOMER_NAME、MERCHANT_GRAY_USERNAME/PASSWORD;不回落 TEST 凭据。` )
}
const fleetPoolGrayOptions = new WeakSet ()
/** Dedicated pool orchestrator transport only: no generic module execution or CLI scope switch. */
export function createFleetPoolGrayOptions ( input ) {
const keys = [ 'gateway' , 'username' , 'password' , 'output' , 'markerNamespace' ]
if ( ! input || Object . keys ( input ). some ( key => ! keys . includes ( key ))
|| keys . some ( key => typeof input [ key ] !== 'string' || ! input [ key ]. trim ())) throw Error ( 'Explicit gray pool transport configuration required' )
const target = new URL ( input . gateway )
if ( target . protocol !== 'https:' || target . username || target . password || target . search || target . hash || target . pathname !== '/' )
throw Error ( 'Gray pool requires the unified HTTPS origin' )
const options = Object . freeze ({ ... input , environment : 'gray' , executionScope : 'FLEET_CAPACITY_POOL_GRAY' ,
modules : Object . freeze ([]), requestedModules : Object . freeze ([]), quiet : true , waitSplitCount : 1 , verifyUnloadEventRecovery : false })
fleetPoolGrayOptions . add ( options )
return options
}
function assertGrayExecutionScope ( options ) {
if ( options . environment !== 'gray' ) return
if ( options . verifyUnloadEventRecovery ) throw new Error ( 'gray 禁止 TEST 故障注入' )
if ( fleetPoolGrayOptions . has ( options )) return
if ( ! Array . isArray ( options . requestedModules ) || options . requestedModules . length !== 1
|| options . requestedModules [ 0 ] !== 'phase5-root-fulfillment'
|| ! Array . isArray ( options . modules ) || options . modules . length !== 2
|| options . modules [ 0 ] !== 'merchant-qualification-fixture'
|| options . modules [ 1 ] !== 'phase5-root-fulfillment' ) {
throw new Error ( 'gray 仅支持显式 phase5-root-fulfillment 及其本轮客户依赖;其他模块尚未核对环境副作用' )
}
if ( options . waitSplitCount !== 1 ) throw new Error ( 'gray 定向闭环只支持单轮订单,不运行批量造数' )
2026-08-08 10:11:04 +08:00
}
2026-08-13 20:00:30 +08:00
export function parseArguments ( argv ) {
2026-08-08 10:11:04 +08:00
if ( argv . includes ( '--help' )) { usage (); process . exit ( 0 ) }
const values = new Map ()
for ( let index = 0 ; index < argv . length ; index += 1 ) {
const argument = argv [ index ]
if ( ! argument . startsWith ( '--' )) throw new Error ( `无法识别的参数: ${ argument } ` )
const separator = argument . indexOf ( '=' )
const name = separator >= 0 ? argument . slice ( 2 , separator ) : argument . slice ( 2 )
const inline = separator >= 0 ? argument . slice ( separator + 1 ) : undefined
if ( inline !== undefined ) values . set ( name , inline )
else {
const next = argv [ index + 1 ]
if ( ! next || next . startsWith ( '--' )) throw new Error ( `参数 -- ${ name } 缺少值` )
values . set ( name , next ); index += 1
}
}
2026-09-29 19:48:34 +08:00
if ( values . has ( 'overseas-gateway' ) || process . env . ADMIN_OVERSEAS_GATEWAY ) throw new Error ( 'Remove overseas-gateway / ADMIN_OVERSEAS_GATEWAY; use one TEST gateway' )
2026-10-08 17:07:57 +08:00
const environment = values . get ( 'environment' ) || 'test'
if ( ! [ 'test' , 'gray' ]. includes ( environment )) throw new Error ( 'Only test or explicit gray acceptance is supported' )
if ( environment === 'gray' && ! values . get ( 'modules' )) throw new Error ( 'gray 必须显式指定 --modules phase5-root-fulfillment,不能运行默认全量' )
const envValue = ( testKey , grayKey ) => process . env [ environment === 'gray' ? grayKey : testKey ] || ''
2026-09-02 19:29:23 +08:00
const requestedModules = ( values . get ( 'modules' ) || DEFAULT_MODULES . join ( ',' ))
. split ( ',' ). map (( v ) => v . trim ()). filter ( Boolean )
const modules = normalizeModuleOrder ( requestedModules )
2026-10-21 21:30:24 +08:00
if ( environment === 'test' ) {
const fixtureIndex = modules . indexOf ( 'merchant-qualification-fixture' )
if ( fixtureIndex >= 0 ) modules . splice ( fixtureIndex , 1 )
modules . unshift ( 'merchant-qualification-fixture' )
}
2026-10-08 17:07:57 +08:00
const merchantUsername = values . get ( 'merchant-username' ) || envValue ( 'MERCHANT_TEST_USERNAME' , 'MERCHANT_GRAY_USERNAME' )
const merchantPassword = values . get ( 'merchant-password' ) || envValue ( 'MERCHANT_TEST_PASSWORD' , 'MERCHANT_GRAY_PASSWORD' )
2026-09-07 21:26:03 +08:00
const requiresMarkerOwnedMerchantFixture = [
2026-09-30 15:16:39 +08:00
'phase5-root-fulfillment' ,
2026-09-07 21:26:03 +08:00
'phase4-overseas-business' ,
2026-09-11 16:02:10 +08:00
'phase4-overseas-return-receipt' ,
2026-09-07 21:26:03 +08:00
'phase4-overseas-client-services' ,
'phase5-overseas-fulfillment' ,
'phase4-overseas-finance-cross-region' ,
'phase4-overseas-wallet-and-pallet-cost' ,
]. some (( module ) => modules . includes ( module ))
2026-10-21 21:30:24 +08:00
if ( environment !== 'test' && merchantUsername && merchantPassword && ! requiresMarkerOwnedMerchantFixture ) {
2026-08-26 11:10:42 +08:00
const fixtureIndex = modules . indexOf ( 'merchant-qualification-fixture' )
if ( fixtureIndex >= 0 ) modules . splice ( fixtureIndex , 1 )
}
2026-10-09 17:21:56 +08:00
if ( values . has ( 'prepare-domestic-pda-h5' ) && values . get ( 'prepare-domestic-pda-h5' ) !== 'true' ) throw new Error ( 'prepare-domestic-pda-h5 必须明确为 true;完整验收请省略此参数' )
2026-10-23 02:13:47 +08:00
if ( values . has ( 'prepare-measurement-review' ) && values . get ( 'prepare-measurement-review' ) !== 'true' ) throw new Error ( 'prepare-measurement-review 必须明确为 true' )
2026-08-08 10:11:04 +08:00
return {
2026-10-08 17:07:57 +08:00
environment ,
gateway : ( values . get ( 'gateway' ) || envValue ( 'ADMIN_TEST_GATEWAY' , 'ADMIN_GRAY_GATEWAY' )). replace ( /\/$/ , '' ),
username : values . get ( 'username' ) || envValue ( 'ADMIN_TEST_USERNAME' , 'ADMIN_GRAY_USERNAME' ),
password : values . get ( 'password' ) || envValue ( 'ADMIN_TEST_PASSWORD' , 'ADMIN_GRAY_PASSWORD' ),
pdaUsername : values . get ( 'pda-username' ) || envValue ( 'ADMIN_PDA_USERNAME' , 'ADMIN_GRAY_PDA_USERNAME' ),
pdaPassword : values . get ( 'pda-password' ) || envValue ( 'ADMIN_PDA_PASSWORD' , 'ADMIN_GRAY_PDA_PASSWORD' ),
2026-08-26 11:10:42 +08:00
modules ,
2026-09-02 19:29:23 +08:00
requestedModules ,
2026-10-23 04:48:02 +08:00
stopFile : values . get ( 'stop-file' ) || '' ,
2026-10-21 21:30:24 +08:00
fixedCustomerId : environment === 'test' ? process . env . ADMIN_TEST_FIXED_CUSTOMER_ID || '' : '' ,
2026-10-08 17:07:57 +08:00
customerName : values . get ( 'customer-name' ) || envValue ( 'ADMIN_TEST_CUSTOMER_NAME' , 'ADMIN_GRAY_CUSTOMER_NAME' ),
2026-08-26 11:10:42 +08:00
merchantUsername ,
merchantPassword ,
originWarehouseName : values . get ( 'origin-warehouse-name' ) || '' ,
channelName : values . get ( 'channel-name' ) || '' ,
channelCode : values . get ( 'channel-code' ) || '' ,
orderProfileMode : values . get ( 'order-profile-mode' ) || 'fixed' ,
2026-10-08 17:07:57 +08:00
waitSplitCount : Number ( values . get ( 'wait-split-count' ) || envValue ( 'ADMIN_WAIT_SPLIT_COUNT' , 'ADMIN_GRAY_WAIT_SPLIT_COUNT' )
2026-09-02 13:03:03 +08:00
|| ( modules . includes ( 'coload-groups' ) ? 8
: modules . includes ( 'manual-order-splitting-fixture' ) ? 6 : 1 )),
2026-08-27 09:21:01 +08:00
waitSplitPackageCount : Number ( values . get ( 'wait-split-package-count' ) || 0 ),
waitSplitSequence : Number ( values . get ( 'wait-split-sequence' ) || 1 ),
waitSplitRandomValue : Number ( values . get ( 'wait-split-random-value' ) || values . get ( 'wait-split-sequence' ) || 1 ),
2026-09-11 16:17:16 +08:00
prepareDropshipManualCount : values . get ( 'prepare-dropship-manual-count' ) === 'true' ,
2026-10-23 02:13:47 +08:00
prepareMeasurementReview : values . get ( 'prepare-measurement-review' ) === 'true' ,
2026-09-04 12:48:55 +08:00
verifyPalletLocationGuide : values . has ( 'verify-pallet-location-guide' ),
2026-08-27 09:21:01 +08:00
routeMode : values . get ( 'route-mode' ) || 'fixed' ,
quiet : values . get ( 'quiet' ) === 'true' ,
2026-09-22 08:49:21 +08:00
returnMainChainOnly : values . get ( 'return-main-chain-only' ) === 'true' ,
2026-10-02 20:14:17 +08:00
verifyUnloadEventRecovery : values . get ( 'verify-unload-event-recovery' ) === 'true' ,
2026-10-08 17:07:57 +08:00
f40TaxMode : values . get ( 'f40-tax-mode' ) || '' ,
2026-10-09 17:21:56 +08:00
prepareDomesticPdaH5 : values . get ( 'prepare-domestic-pda-h5' ) === 'true' ,
2026-08-26 16:50:22 +08:00
manualSplitFixtureReport : values . get ( 'manual-split-fixture-report' ) || '' ,
manualSplitPlanId : values . get ( 'manual-split-plan-id' ) || '' ,
manualPreloadSourceReport : values . get ( 'manual-preload-source-report' ) || '' ,
manualPdaSourceReport : values . get ( 'manual-pda-source-report' ) || '' ,
manualPdaBatchSourceReport : values . get ( 'manual-pda-batch-source-report' ) || '' ,
manualLoadingCompleteSourceReport : values . get ( 'manual-loading-complete-source-report' ) || '' ,
2026-08-08 10:11:04 +08:00
output : values . get ( 'output' ) || '' ,
}
}
2026-08-24 19:58:35 +08:00
export function validateExecutionOptions ( options ) {
2026-10-08 17:07:57 +08:00
assertGrayExecutionScope ( options )
if ( options . environment === 'gray' ) {
const target = new URL ( options . gateway )
if ( target . protocol !== 'https:' || target . username || target . password || target . search || target . hash
|| target . pathname !== '/' ) throw new Error ( 'gray gateway 必须为统一 HTTPS 源站,不带 /api、凭据或查询参数' )
if ( ! options . output ? . trim ()) throw new Error ( 'gray 必须指定独立 --output 报告路径' )
}
2026-10-02 20:14:17 +08:00
if ( options . verifyUnloadEventRecovery && ! options . modules . includes ( 'phase4-overseas-pda' )) {
throw new Error ( '卸柜事件恢复验收必须包含正式 phase4-overseas-pda 收货链' )
}
2026-08-26 16:50:22 +08:00
const unavailable = options . modules . filter (( module ) => ! AVAILABLE_MODULES . has ( module ))
if ( unavailable . length > 0 ) throw new Error ( `未注册的 HTTP 验收模块: ${ unavailable . join ( ',' ) } ` )
2026-08-24 19:58:35 +08:00
for ( const [ name , value ] of [[ 'gateway' , options . gateway ], [ 'username' , options . username ], [ 'password' , options . password ]]) {
if ( typeof value !== 'string' || value . trim () === '' ) throw new Error ( `缺少必填执行参数: ${ name } ` )
}
2026-10-08 17:07:57 +08:00
if ( options . environment && ! [ 'test' , 'gray' ]. includes ( options . environment )) throw new Error ( 'Only test or explicit gray acceptance is supported' )
if ( options . f40TaxMode && ! [ 'BUY_DECLARATION' , 'TAX_REFUND' ]. includes ( options . f40TaxMode )) throw new Error ( 'f40-tax-mode 必须为 BUY_DECLARATION 或 TAX_REFUND' )
2026-10-09 17:21:56 +08:00
if ( options . prepareDomesticPdaH5 && ( options . requestedModules ? . length !== 1 || options . requestedModules [ 0 ] !== 'phase5-root-fulfillment' )) throw new Error ( 'prepare-domestic-pda-h5 仅允许单独指定 phase5-root-fulfillment' )
2026-10-23 02:13:47 +08:00
if ( options . prepareMeasurementReview && ( options . environment !== 'test'
|| options . requestedModules ? . length !== 1 || options . requestedModules [ 0 ] !== 'inbound-measurement'
|| options . waitSplitCount !== 1 || options . prepareDomesticPdaH5 || options . prepareDropshipManualCount )) {
throw new Error ( 'prepare-measurement-review 仅允许 TEST 单票 inbound-measurement 待审准备,不能混用其他准备模式或后续模块' )
}
2026-09-29 19:48:34 +08:00
if ( 'overseasGateway' in options ) throw new Error ( 'Use the single gateway option' )
2026-09-30 15:16:39 +08:00
if ( options . modules . some (( module ) => [ 'phase3-loading' , 'phase3-container-loading' , 'phase5-root-fulfillment' ]. includes ( module ))
2026-09-24 04:38:38 +08:00
&& ( ! options . pdaUsername ? . trim () || ! options . pdaPassword ? . trim ())) {
throw new Error ( '国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password( ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD) ' )
}
2026-08-26 11:10:42 +08:00
if ( ! Number . isInteger ( options . waitSplitCount ) || options . waitSplitCount < 1 || options . waitSplitCount > 100 ) {
throw new Error ( 'wait-split-count必须是1-100的整数' )
}
2026-08-27 09:21:01 +08:00
if ( ! Number . isInteger ( options . waitSplitPackageCount ) || options . waitSplitPackageCount < 0
|| options . waitSplitPackageCount > 1000 ) {
throw new Error ( 'wait-split-package-count必须是1-1000的整数或0' )
}
if ( ! Number . isInteger ( options . waitSplitSequence ) || options . waitSplitSequence < 1 ) {
throw new Error ( 'wait-split-sequence必须是正整数' )
}
if ( ! Number . isInteger ( options . waitSplitRandomValue ) || options . waitSplitRandomValue < 1 ) {
throw new Error ( 'wait-split-random-value必须是正整数' )
}
2026-08-26 11:10:42 +08:00
if ( options . customerName && options . customerName . trim (). length > 128 ) {
throw new Error ( 'customer-name不能超过128个字符' )
}
if ( Boolean ( options . merchantUsername ) !== Boolean ( options . merchantPassword )) {
throw new Error ( 'merchant-username和merchant-password必须同时提供' )
}
for ( const [ name , value ] of [[ 'origin-warehouse-name' , options . originWarehouseName ],
[ 'channel-name' , options . channelName ], [ 'channel-code' , options . channelCode ]]) {
if ( value && value . trim (). length > 128 ) throw new Error ( ` ${ name } 不能超过128个字符` )
}
if ( ! [ 'fixed' , 'randomized' ]. includes ( options . orderProfileMode )) {
throw new Error ( 'order-profile-mode必须为fixed或randomized' )
}
2026-08-27 09:21:01 +08:00
if ( ! [ 'fixed' , 'random' ]. includes ( options . routeMode )) {
throw new Error ( 'route-mode必须为fixed或random' )
}
if ( options . routeMode === 'random' && ! options . originWarehouseName ) {
throw new Error ( 'random线路必须固定起运仓名称' )
}
if ( options . orderProfileMode === 'randomized' && options . routeMode !== 'random'
2026-08-26 11:10:42 +08:00
&& ( ! options . originWarehouseName || ! options . channelName || ! options . channelCode )) {
throw new Error ( 'randomized订单档案必须同时固定起运仓、渠道名称和渠道编码' )
}
2026-09-11 16:17:16 +08:00
if ( options . prepareDropshipManualCount
&& ( ! options . modules . includes ( 'orders' ) || ! options . modules . includes ( 'inbound-measurement' ))) {
throw new Error ( 'prepare-dropship-manual-count要求同时执行orders和inbound-measurement模块' )
}
2026-08-26 11:10:42 +08:00
if ( options . waitSplitCount > 1 ) {
2026-08-26 16:50:22 +08:00
const fixtureModules = new Set ([ 'merchant-qualification-fixture' , 'orders' , 'inbound-measurement' ,
2026-09-02 19:29:23 +08:00
'manual-order-splitting-fixture' , 'coload-groups' , 'firstleg-container-fee-allocation' ])
2026-08-26 11:10:42 +08:00
if ( options . modules . some (( module ) => ! fixtureModules . has ( module ))) {
2026-09-02 19:29:23 +08:00
throw new Error ( '批量WAIT_SPLIT只允许执行WAIT_SPLIT造数、同柜组与柜次跨客户分摊模块' )
2026-08-26 16:50:22 +08:00
}
}
if ( options . modules . includes ( 'manual-order-splitting-fixture' ) && options . waitSplitCount < 4 ) {
throw new Error ( 'manual-order-splitting-fixture要求wait-split-count至少为4' )
}
if ( options . modules . includes ( 'manual-order-splitting-verify' )) {
if ( ! options . manualSplitFixtureReport ) throw new Error ( 'manual-order-splitting-verify缺少manual-split-fixture-report' )
if ( ! /^[1-9][0-9]*$/ . test ( String ( options . manualSplitPlanId ))) {
throw new Error ( 'manual-order-splitting-verify要求manual-split-plan-id为正整数' )
}
}
for ( const module of [ 'manual-preload-push-fixture' , 'manual-preload-push-verify' ]) {
if ( options . modules . includes ( module ) && ! options . manualPreloadSourceReport ) {
throw new Error ( ` ${ module } 缺少manual-preload-source-report` )
}
}
for ( const module of [ 'manual-pda-ready-fixture' , 'manual-pda-ready-verify' ]) {
if ( options . modules . includes ( module ) && ! options . manualPdaSourceReport ) {
throw new Error ( ` ${ module } 缺少manual-pda-source-report` )
}
}
for ( const module of [ 'manual-pda-batch-fixture' , 'manual-pda-batch-verify' ]) {
if ( options . modules . includes ( module ) && ! options . manualPdaBatchSourceReport ) {
throw new Error ( ` ${ module } 缺少manual-pda-batch-source-report` )
}
}
for ( const module of [ 'manual-loading-complete-fixture' , 'manual-loading-complete-verify' ]) {
if ( options . modules . includes ( module ) && ! options . manualLoadingCompleteSourceReport ) {
throw new Error ( ` ${ module } 缺少manual-loading-complete-source-report` )
2026-08-26 11:10:42 +08:00
}
}
2026-08-24 19:58:35 +08:00
return options
}
2026-08-08 10:11:04 +08:00
function own ( value , field ) { return value !== null && typeof value === 'object' && Object . hasOwn ( value , field ) }
function hasFields ( value , fields ) { return fields . every (( field ) => own ( value , field )) }
function numberEquals ( actual , expected ) { return Math . abs ( Number ( actual ) - Number ( expected )) < 0.0005 }
function halfUp3 ( value ) { return Math . round (( Number ( value ) + Number . EPSILON ) * 1000 ) / 1000 }
function derived ( length , width , height , edge ) {
2026-09-04 15:44:00 +08:00
const outerLength = Number ( length ) + Number ( edge ) * 2
const outerWidth = Number ( width ) + Number ( edge ) * 2
const outerHeight = Number ( height ) + Number ( edge ) * 2
2026-08-08 10:11:04 +08:00
return { outerLength , outerWidth , outerHeight , volume : halfUp3 ( outerLength * outerWidth * outerHeight / 1_000_000 ) }
}
2026-08-25 09:50:02 +08:00
export function buildAcceptanceSummary ( steps = [], modules = [], coverageGaps = []) {
const failed = steps . filter (( step ) => ! step . passed ). length
const executedModules = modules . filter (( module ) => ! module . skipped )
const uniqueModuleNames = new Set ( executedModules . map (( module ) => module . name ). filter ( Boolean ))
const uniqueRequestKeys = new Set ()
const uniqueStepNameSet = new Set ()
for ( const step of steps ) {
if ( step . name ) uniqueStepNameSet . add ( step . name )
const method = step . request ? . method
const path = step . request ? . path
if ( ! method || ! path ) continue
uniqueRequestKeys . add ( ` ${ method } \u001f ${ path } \u001f ${ step . requestId || '' } ` )
}
const coverageGapStats = {
total : coverageGaps . length ,
blocking : 0 ,
nonBlocking : 0 ,
byCategory : {},
}
for ( const gap of coverageGaps ) {
const category = gap . category || 'uncategorized'
const blocking = gap . blocking === true
const categoryStats = coverageGapStats . byCategory [ category ]
|| { total : 0 , blocking : 0 , nonBlocking : 0 }
categoryStats . total += 1
if ( blocking ) {
coverageGapStats . blocking += 1
categoryStats . blocking += 1
} else {
coverageGapStats . nonBlocking += 1
categoryStats . nonBlocking += 1
}
coverageGapStats . byCategory [ category ] = categoryStats
}
return {
total : steps . length ,
passed : steps . length - failed ,
failed ,
moduleRuns : executedModules . length ,
uniqueModules : uniqueModuleNames . size ,
uniqueRequests : uniqueRequestKeys . size ,
uniqueStepNames : uniqueStepNameSet . size ,
skippedModules : modules . length - executedModules . length ,
coverageGaps : coverageGapStats ,
}
}
2026-08-12 10:59:39 +08:00
export class AcceptanceRun {
2026-08-08 10:11:04 +08:00
constructor ( options ) {
2026-10-08 17:07:57 +08:00
assertGrayExecutionScope ( options )
2026-10-21 21:30:24 +08:00
this . options = { ... options }
if ( options . environment === 'test' ) {
this . options . fixedCustomerId ||= process . env . ADMIN_TEST_FIXED_CUSTOMER_ID || ''
this . options . merchantUsername ||= process . env . MERCHANT_TEST_USERNAME || ''
this . options . merchantPassword ||= process . env . MERCHANT_TEST_PASSWORD || ''
2026-10-22 11:56:47 +08:00
this . options . fixedPdaActors ??= JSON . parse ( process . env . ADMIN_TEST_OVERSEAS_PDA_ACTORS || '[]' )
this . options . fixedDomesticDenyActor ??= JSON . parse ( process . env . ADMIN_TEST_DOMESTIC_DENY_ACTOR || 'null' )
this . options . fixedFleetResources ??= JSON . parse ( process . env . ADMIN_TEST_FLEET_RESOURCES || '[]' )
2026-10-23 04:48:02 +08:00
if ( ! this . options . fixedSecondaryCustomer && process . env . ADMIN_TEST_SECONDARY_CUSTOMER_ID ) {
this . options . fixedSecondaryCustomer = { customerId : process . env . ADMIN_TEST_SECONDARY_CUSTOMER_ID ,
username : process . env . ADMIN_TEST_SECONDARY_MERCHANT_USERNAME || '' , password : process . env . ADMIN_TEST_SECONDARY_MERCHANT_PASSWORD || '' }
}
2026-10-21 21:30:24 +08:00
}
2026-08-27 09:21:01 +08:00
const markerNamespace = String ( options . markerNamespace || '' ). replace ( /[^A-Za-z0-9_-]/g , '' ). slice ( 0 , 12 )
2026-10-08 17:07:57 +08:00
this . marker = ` ${ options . environment === 'gray' ? 'GRAY' : 'ADMIN' } -API- ${ new Date (). toISOString (). replace ( /\D/g , '' ). slice ( 0 , 17 ) }${ markerNamespace ? `- ${ markerNamespace } ` : '' } `
2026-08-08 10:11:04 +08:00
this . startedAt = new Date (). toISOString ()
this . steps = []
this . modules = []
this . scenarios = []
this . createdResources = {}
2026-09-10 21:18:52 +08:00
this . phase4ProducerAttempts = new Map ()
2026-08-08 10:11:04 +08:00
this . deploymentBlockers = []
2026-08-08 15:25:46 +08:00
this . coverageGaps = []
2026-10-09 17:21:56 +08:00
this . context = { ...( options . f40TaxMode ? { f40TaxMode : options . f40TaxMode } : {}), ...( options . prepareDomesticPdaH5 ? { prepareDomesticPdaH5 : true } : {}) }
2026-08-08 10:11:04 +08:00
this . token = ''
2026-09-23 18:32:23 +08:00
this . pdaToken = ''
2026-08-25 16:40:48 +08:00
this . refreshToken = ''
2026-09-29 19:48:34 +08:00
this . expiresAt = 0
2026-08-08 10:11:04 +08:00
this . fatal = null
}
2026-10-21 21:30:24 +08:00
assertCustomerWriteAllowed ( method , path ) {
if ( this . options . environment !== 'test' ) return
const pathname = new URL ( path , 'https://acceptance.invalid' ). pathname . replace ( /\/$/ , '' )
if ( String ( method ). toUpperCase () === 'POST' && ( pathname === '/api/platform/v1/iam/tenants'
2026-10-22 11:56:47 +08:00
|| pathname === '/api/auth/register'
2026-10-21 21:30:24 +08:00
|| /^\/api\/platform\/v1\/iam\/tenants\/[^/]+\/(archive|submit-review)$/ . test ( pathname ))) {
throw new Error ( 'TEST 固定客户模式禁止创建、归档客户或重提资质;请复用已配置客户' )
}
}
2026-08-12 09:25:53 +08:00
async request ( method , path , body = undefined , token = this . token , extraHeaders = {}) {
2026-10-21 21:30:24 +08:00
this . assertCustomerWriteAllowed ( method , path )
2026-08-12 09:25:53 +08:00
const headers = { Accept : 'application/json' , ... extraHeaders }
2026-09-23 18:32:23 +08:00
if ( this . pdaToken && /\/pda-loading\/tasks\/\d+/ . test ( String ( path ))) token = this . pdaToken
2026-08-08 10:11:04 +08:00
if ( token ) headers . Authorization = `Bearer ${ token } `
if ( body !== undefined ) headers [ 'Content-Type' ] = 'application/json; charset=utf-8'
2026-09-02 19:29:23 +08:00
const maxAttempts = [ 'GET' , 'HEAD' ]. includes ( String ( method ). toUpperCase ()) ? 3 : 1
for ( let attempt = 1 ; attempt <= maxAttempts ; attempt += 1 ) {
try {
const response = await fetch ( ` ${ this . options . gateway }${ path } ` , {
2026-09-03 18:13:00 +08:00
method , headers , body : body === undefined ? undefined : JSON . stringify ( body ),
2026-09-02 19:29:23 +08:00
signal : AbortSignal . timeout ( 30_000 ),
})
const content = await response . text ()
let payload = null
try { payload = content ? JSON . parse ( content ) : null } catch { /* Raw response is recorded below. */ }
return {
method , path , http : response . status , body : payload ,
raw : payload === null ? content . slice ( 0 , 500 ) : null ,
requestId : payload ? . requestId || response . headers . get ( 'x-request-id' ),
traceId : payload ? . traceId || response . headers . get ( 'x-trace-id' ),
}
} catch ( error ) {
if ( attempt < maxAttempts ) {
await new Promise (( resolve ) => setTimeout ( resolve , attempt * 250 ))
continue
}
2026-10-08 19:09:30 +08:00
return { method , path , http : 0 , body : null , raw : error instanceof Error ? error . message : String ( error ), requestId : null , traceId : null , transportError : transportFailureDetails ( error ) }
2026-08-08 10:11:04 +08:00
}
}
2026-09-02 19:29:23 +08:00
throw new Error ( 'unreachable request attempt state' )
2026-08-08 10:11:04 +08:00
}
2026-08-12 10:59:39 +08:00
async requestMultipart ( method , path , file , token = this . token , extraHeaders = {}) {
2026-09-23 18:40:32 +08:00
if ( this . pdaToken && /\/pda-loading\/tasks\/\d+/ . test ( String ( path ))) token = this . pdaToken
2026-08-12 10:59:39 +08:00
const form = file instanceof FormData ? file : new FormData ()
if ( ! ( file instanceof FormData )) {
if ( ! file || file . content === undefined ) throw new TypeError ( 'multipart file requires content' )
const content = file . content instanceof Uint8Array ? file . content : new Uint8Array ( file . content )
form . append ( 'file' , new Blob ([ content ], { type : file . type || 'application/octet-stream' }), file . name || 'fixture.bin' )
}
return this . requestWithBody ( method , path , form , token , extraHeaders )
}
async requestWithBody ( method , path , body , token = this . token , extraHeaders = {}) {
2026-10-21 21:30:24 +08:00
this . assertCustomerWriteAllowed ( method , path )
2026-08-12 10:59:39 +08:00
const headers = { Accept : 'application/json' , ... extraHeaders }
if ( token ) headers . Authorization = `Bearer ${ token } `
if ( ! ( body instanceof FormData ) && body !== undefined ) headers [ 'Content-Type' ] = 'application/json; charset=utf-8'
try {
const response = await fetch ( ` ${ this . options . gateway }${ path } ` , {
method , headers , body : body === undefined ? undefined : body instanceof FormData ? body : JSON . stringify ( body ),
signal : AbortSignal . timeout ( 60_000 ),
})
const content = await response . text (); let payload = null
try { payload = content ? JSON . parse ( content ) : null } catch { /* bounded raw response */ }
return { method , path , http : response . status , body : payload ,
raw : payload === null ? content . slice ( 0 , 500 ) : null ,
requestId : payload ? . requestId || response . headers . get ( 'x-request-id' ),
traceId : payload ? . traceId || response . headers . get ( 'x-trace-id' ) }
} catch ( error ) {
2026-10-08 19:09:30 +08:00
return { method , path , http : 0 , body : null , raw : error instanceof Error ? error . message : String ( error ), requestId : null , traceId : null , transportError : transportFailureDetails ( error ) }
2026-08-12 10:59:39 +08:00
}
}
2026-08-08 10:11:04 +08:00
async requestBinary ( method , path ) {
2026-09-23 18:50:06 +08:00
const token = this . pdaToken && /\/pda-loading\/tasks\/\d+/ . test ( String ( path )) ? this . pdaToken : this . token
const headers = { Accept : '*/*' , ...( token ? { Authorization : `Bearer ${ token } ` } : {}) }
2026-09-04 17:17:08 +08:00
const maxAttempts = [ 'GET' , 'HEAD' ]. includes ( String ( method ). toUpperCase ()) ? 3 : 1
for ( let attempt = 1 ; attempt <= maxAttempts ; attempt += 1 ) {
try {
const response = await fetch ( ` ${ this . options . gateway }${ path } ` , { method , headers , signal : AbortSignal . timeout ( 120_000 ) })
const bytes = new Uint8Array ( await response . arrayBuffer ())
const contentType = response . headers . get ( 'content-type' ) || ''
let payload = null
if ( contentType . toLowerCase (). includes ( 'json' )) {
try { payload = JSON . parse ( new TextDecoder (). decode ( bytes )) } catch { /* Invalid envelope remains binary evidence. */ }
}
return {
method , path , http : response . status , body : payload , bytes , contentType ,
raw : payload ? null : ` ${ bytes . length } bytes ( ${ contentType || 'unknown' } )` ,
requestId : payload ? . requestId || response . headers . get ( 'x-request-id' ),
traceId : payload ? . traceId || response . headers . get ( 'x-trace-id' ),
}
} catch ( error ) {
if ( attempt < maxAttempts ) {
await new Promise (( resolve ) => setTimeout ( resolve , attempt * 250 ))
continue
}
return { method , path , http : 0 , body : null , bytes : new Uint8Array (), contentType : '' , raw : String ( error ), requestId : null , traceId : null }
2026-08-08 10:11:04 +08:00
}
}
2026-09-04 17:17:08 +08:00
throw new Error ( 'unreachable binary request attempt state' )
2026-08-08 10:11:04 +08:00
}
addStep ( module , name , response , passed , evidence = '' , options = {}) {
const step = {
module , scenario : options . scenario || null , name , passed : Boolean ( passed ),
deploymentBlocker : Boolean ( options . deploymentBlocker && ! passed ),
request : response ? { method : response . method , path : response . path } : null ,
http : response ? . http ?? null ,
code : response ? . body ? . code === undefined ? null : String ( response . body . code ),
message : response ? . body ? . message || response ? . raw || null ,
requestId : response ? . requestId || null , traceId : response ? . traceId || null ,
2026-10-08 19:09:30 +08:00
...( response ? . transportError ? { transportError : response . transportError } : {}),
2026-08-08 10:11:04 +08:00
evidence , checkedAt : new Date (). toISOString (),
}
this . steps . push ( step )
if ( step . deploymentBlocker ) this . deploymentBlockers . push ({ module , scenario : step . scenario , name , evidence , code : step . code , requestId : step . requestId , traceId : step . traceId })
2026-08-27 09:21:01 +08:00
if ( ! this . options . quiet ) {
console . log ( `[ ${ step . passed ? 'PASS' : 'FAIL' } ] [ ${ module }${ step . scenario ? `/ ${ step . scenario } ` : '' } ] ${ name }${ evidence ? ` - ${ evidence } ` : '' } ` )
}
2026-08-08 10:11:04 +08:00
return step . passed
}
2026-08-08 22:42:49 +08:00
addCoverageGap ( module , scenario , name , reason , category = 'not-implemented' , metadata = {}) {
2026-08-24 19:58:35 +08:00
const nonBlocking = new Set ([ 'device' , 'external-third-party' , 'visual' ])
const blocking = metadata . blocking ?? ! nonBlocking . has ( category )
2026-09-06 11:41:46 +08:00
const gap = { module , scenario , name , reason , category , blocking , ... metadata }
const duplicate = this . coverageGaps . some (( existing ) => existing . module === gap . module
&& existing . scenario === gap . scenario && existing . name === gap . name
&& existing . reason === gap . reason && existing . category === gap . category
&& existing . blocking === gap . blocking )
if ( duplicate ) return
this . coverageGaps . push ( gap )
2026-08-24 19:58:35 +08:00
if ( blocking ) {
this . addStep ( module , `覆盖阻断: ${ name } ` , null , false , reason ,
{ scenario , deploymentBlocker : true })
}
2026-08-08 15:25:46 +08:00
}
2026-08-08 10:11:04 +08:00
apiSuccess ( module , name , response , evidence = '' , options = {}) {
const passed = response . http === 200 && String ( response . body ? . code ) === '200'
this . addStep ( module , name , response , passed , evidence , options )
return passed ? response . body . data : null
}
assert ( module , name , response , condition , evidence , options = {}) {
return this . addStep ( module , name , response , Boolean ( condition ), evidence , options )
}
expectConflict ( module , name , response , scenario ) {
const passed = response . http === 200 && [ 'A0409' , '409' ]. includes ( String ( response . body ? . code ))
this . addStep ( module , name , response , passed , `HTTP= ${ response . http } , code= ${ response . body ? . code ?? 'missing' } ` , { scenario , deploymentBlocker : true })
return passed
}
async executeModule ( name , handler ) {
2026-10-22 14:37:59 +08:00
if ( this . options . fixedCustomerId && FIXED_RESOURCE_SKIPPED_MODULES . has ( name )) {
2026-10-21 21:30:24 +08:00
this . addCoverageGap ( name , 'fixed-resource-lifecycle' , '固定资源模式排除新增主档及账户生命周期' ,
'业务流程复用现有资源;不执行主档创建、账号创建、价格发布或钱包初始化' , 'not-implemented' , { blocking : false })
this . modules . push ({ name , success : false , skipped : true , passed : 0 , failed : 0 ,
reason : '固定资源模式排除生命周期;不计为验收通过' })
return
}
2026-08-08 10:11:04 +08:00
const startedAt = new Date (). toISOString (); const start = this . steps . length ; let fatal = null
try { await handler () } catch ( error ) {
fatal = error instanceof Error ? error . message : String ( error )
this . addStep ( name , '模块执行' , null , false , fatal )
}
const steps = this . steps . slice ( start )
this . modules . push ({ name , startedAt , finishedAt : new Date (). toISOString (), success : fatal === null && steps . every (( s ) => s . passed ), passed : steps . filter (( s ) => s . passed ). length , failed : steps . filter (( s ) => ! s . passed ). length , fatal })
}
2026-10-23 04:48:02 +08:00
/** 在模块边界结束,随后仍由main的finally恢复故障注入配置并写出失败报告。 */
async stopAtBoundary () {
if ( this . stopRequested ) return true
if ( ! this . options . stopFile ) return false
try { await readFile ( this . options . stopFile ) }
catch ( error ) { if ( error . code === 'ENOENT' ) return false ; throw error }
this . stopRequested = true
this . fatal = '收到停止文件,未继续执行后续模块;本报告为中止结果'
this . addStep ( 'execution-control' , '停止后续模块并进入正常清理' , null , false ,
'等待当前模块边界,不强杀进程;故障配置清理由总入口finally执行' , { deploymentBlocker : true })
return true
}
2026-08-26 11:10:42 +08:00
captureWaitSplitFixture ( sequence ) {
const order = this . createdResources . orders || {}
const measurement = this . createdResources . inboundMeasurement || {}
2026-09-11 16:34:40 +08:00
const expectedProcessStatus = this . options . prepareDropshipManualCount ? 'WAIT_PALLETIZE' : 'WAIT_SPLIT'
2026-08-26 11:10:42 +08:00
const ready = Boolean ( order . orderId && measurement . inboundId
&& String ( order . tenantId ) === String ( measurement . tenantId )
2026-09-11 16:34:40 +08:00
&& measurement . processStatus === expectedProcessStatus && measurement . finalStatus === 'APPROVED' )
this . assert ( 'wait-split-fixtures' , this . options . prepareDropshipManualCount
? `固定客户第 ${ sequence } 单停在PDA清点前` : `固定客户第 ${ sequence } 单停在待分柜` , null , ready ,
2026-08-26 11:10:42 +08:00
`tenantId= ${ measurement . tenantId || 'missing' } , orderId= ${ order . orderId || 'missing' } , inboundId= ${ measurement . inboundId || 'missing' } , processStatus= ${ measurement . processStatus || 'missing' } , reviewStatus= ${ measurement . finalStatus || 'missing' } ` ,
{ scenario : 'fixed-customer-wait-split' , deploymentBlocker : true })
if ( ! ready ) return false
this . createdResources . waitSplitFixtures ??= []
this . createdResources . waitSplitFixtures . push ({
sequence , tenantId : String ( measurement . tenantId ), orderId : String ( order . orderId ),
orderNo : order . orderNo || null , inboundId : String ( measurement . inboundId ),
inboundCode : order . inboundCode || null , processStatus : measurement . processStatus ,
reviewStatus : measurement . finalStatus , skuCount : order . skuLines ? . length || null ,
2026-09-11 16:34:40 +08:00
totalPackageCount : measurement . actualPackageCount
|| ( order . skuLines || []). reduce (( sum , item ) => sum + Number ( item . packageCount || 0 ), 0 ),
2026-08-26 11:10:42 +08:00
palletCount : measurement . palletCount || null ,
actualGrossWeightKg : measurement . actualGrossWeightKg || null ,
actualNetWeightKg : measurement . actualNetWeightKg || null ,
measurementProfile : this . context . waitSplitVariant ? . measurementProfile || null ,
receivingWarehouseId : order . receivingWarehouseId || null ,
receivingWarehouseName : order . receivingWarehouseName || null ,
destinationWarehouseId : order . destinationWarehouseId || null ,
destinationWarehouseCode : order . destinationWarehouseCode || null ,
destinationWarehouseName : order . destinationWarehouseName || null ,
channelCode : order . channelCode || null ,
channelName : order . channelName || null ,
pricingTaxMode : order . pricingTaxMode || null ,
destinationType : order . destinationType || null ,
fulfillmentType : order . fulfillmentType || null ,
pickupMethod : order . pickupMethod || null ,
taxMode : order . taxMode || null ,
containerMode : order . containerMode || null ,
customsInspection : order . customsInspection ?? null ,
})
return true
}
waitSplitVariant ( sequence ) {
const dimensionProfiles = [
2026-09-03 16:09:22 +08:00
{ code : 'SMALL' , first : { length : 29.1 , width : 19.1 , height : 18.1 }, replacement : { length : 29.5 , width : 19.5 , height : 18.5 } },
{ code : 'MEDIUM' , first : { length : 39.1 , width : 29.1 , height : 28.1 }, replacement : { length : 39.5 , width : 29.5 , height : 28.5 } },
{ code : 'LARGE' , first : { length : 49.1 , width : 39.1 , height : 38.1 }, replacement : { length : 49.5 , width : 39.5 , height : 38.5 } },
{ code : 'EXTRA_LARGE' , first : { length : 59.1 , width : 49.1 , height : 48.1 }, replacement : { length : 59.5 , width : 49.5 , height : 48.5 } },
2026-08-26 11:10:42 +08:00
]
2026-08-27 09:21:01 +08:00
const randomValue = this . options . waitSplitRandomValue || sequence
const requestedTotal = Number ( this . options . waitSplitPackageCount || 0 )
const skuCount = requestedTotal > 0
? 1 + ( randomValue % Math . min ( 4 , requestedTotal ))
: 1 + (( sequence - 1 ) % 4 )
const packageCountPerSku = requestedTotal > 0 ? 1 : 1 + ( Math . floor (( sequence - 1 ) / 4 ) % 5 )
const packageCounts = []
if ( requestedTotal > 0 ) {
let remaining = requestedTotal
let state = randomValue >>> 0
for ( let index = 0 ; index < skuCount - 1 ; index += 1 ) {
state = ( Math . imul ( state , 1664525 ) + 1013904223 ) >>> 0
const maximum = remaining - ( skuCount - index - 1 )
const count = 1 + ( state % maximum )
packageCounts . push ( count )
remaining -= count
}
packageCounts . push ( remaining )
}
const totalPackageCount = requestedTotal || skuCount * packageCountPerSku
const palletCount = requestedTotal > 0 ? Math . ceil ( totalPackageCount / 30 ) : 1 + ( randomValue % 3 )
const grossWeightPerPalletKg = 45 + ( randomValue % 6 ) * 15
2026-08-26 11:10:42 +08:00
return {
2026-08-27 09:21:01 +08:00
sequence , skuCount , packageCountPerSku , packageCounts ,
totalPackageCount ,
palletCount , grossWeightPerPalletKg ,
measurementProfile : dimensionProfiles [ randomValue % dimensionProfiles . length ],
2026-08-26 11:10:42 +08:00
}
}
randomizedOrderProfile ( sequence ) {
const combinations = [
[ 'FBA' , 'STANDARD' ],
[ 'OVERSEAS' , 'STANDARD' ],
[ 'PRIVATE' , 'STANDARD' ],
[ 'FBA' , 'ONE_CLICK_DROPSHIP' ],
[ 'OVERSEAS' , 'ONE_CLICK_DROPSHIP' ],
[ 'PRIVATE' , 'ONE_CLICK_DROPSHIP' ],
]
2026-08-27 09:21:01 +08:00
const randomValue = this . options . waitSplitRandomValue || sequence
const [ destinationType , fulfillmentType ] = combinations [ randomValue % combinations . length ]
2026-08-26 11:10:42 +08:00
const pickupMethods = [ 'PICKUP' , 'SELF_EXPRESS' , 'SELF_TRUCK' ]
return {
destinationType ,
fulfillmentType ,
2026-08-27 09:21:01 +08:00
pickupMethod : pickupMethods [ Math . floor ( randomValue / 7 ) % pickupMethods . length ],
taxMode : randomValue % 2 === 0 ? 'TAX_REFUND' : 'BUY_DECLARATION' ,
containerMode : Math . floor ( randomValue / 11 ) % 2 === 0 ? 'FULL_CONTAINER' : 'LCL' ,
customsInspection : Math . floor ( randomValue / 13 ) % 4 === 0 ,
2026-08-26 11:10:42 +08:00
}
}
applyWaitSplitVariant ( sequence ) {
const variant = this . waitSplitVariant ( sequence )
this . context . waitSplitVariant = variant
this . context . phase3LoadingIncrementalDetailCount = variant . skuCount
this . context . phase3LoadingIncrementalPackageCount = variant . packageCountPerSku
const profile = this . options . orderProfileMode === 'randomized'
? this . randomizedOrderProfile ( sequence )
2026-09-02 13:03:03 +08:00
: { destinationType : 'FBA' , fulfillmentType : 'STANDARD' , pickupMethod : 'SELF_EXPRESS' ,
2026-08-26 11:10:42 +08:00
taxMode : 'BUY_DECLARATION' , containerMode : 'LCL' , customsInspection : false }
this . context . orderProfile = profile
this . context . orderDestinationType = profile . destinationType
this . context . orderFulfillmentType = profile . fulfillmentType
this . context . waitSplitRoute = {
originWarehouseName : this . options . originWarehouseName || '' ,
channelName : this . options . channelName || '' ,
channelCode : this . options . channelCode || '' ,
2026-08-27 09:21:01 +08:00
randomize : this . options . routeMode === 'random' ,
randomIndex : this . options . waitSplitRandomValue || sequence ,
2026-08-26 11:10:42 +08:00
destinationIndex : ( sequence * 17 + 11 ),
taxModeIndex : sequence - 1 ,
}
}
async createAdditionalWaitSplitFixtures () {
const total = this . options . waitSplitCount
if ( total <= 1 ) return
const baseMarker = this . marker
const orders = await import ( new URL ( './admin-test-env-modules/orders.mjs' , import . meta . url ))
const measurement = await import ( new URL ( './admin-test-env-modules/inbound-measurement.mjs' , import . meta . url ))
try {
for ( let sequence = 2 ; sequence <= total ; sequence += 1 ) {
this . marker = ` ${ baseMarker } -WS ${ String ( sequence ). padStart ( 2 , '0' ) } `
this . applyWaitSplitVariant ( sequence )
2026-08-26 17:09:02 +08:00
const orderStepStart = this . steps . length
2026-08-26 11:10:42 +08:00
await this . executeModule ( 'orders' , () => orders . run ( this ))
2026-08-26 17:09:02 +08:00
const orderRun = this . modules . at ( - 1 )
const orderBlockingFailure = Boolean ( orderRun ? . fatal )
|| this . steps . slice ( orderStepStart ). some (( step ) => ! step . passed && step . deploymentBlocker )
if ( orderBlockingFailure ) break
const measurementStepStart = this . steps . length
2026-08-26 11:10:42 +08:00
await this . executeModule ( 'inbound-measurement' , () => measurement . run ( this ))
2026-08-26 17:09:02 +08:00
const measurementRun = this . modules . at ( - 1 )
const measurementBlockingFailure = Boolean ( measurementRun ? . fatal )
|| this . steps . slice ( measurementStepStart ). some (( step ) => ! step . passed && step . deploymentBlocker )
if ( measurementBlockingFailure || ! this . captureWaitSplitFixture ( sequence )) break
2026-08-26 11:10:42 +08:00
}
} finally {
this . marker = baseMarker
}
}
2026-08-25 16:40:48 +08:00
async refreshSession ( reason ) {
const response = await this . request ( 'POST' , '/api/auth/refresh' ,
{ refreshToken : this . refreshToken }, '' )
2026-09-29 19:48:34 +08:00
const next = this . apiSuccess ( 'authentication' , '刷新统一会话' , response ,
`reason= ${ reason } ` , { scenario : 'session-refresh' , deploymentBlocker : true })
const valid = Boolean ( next ? . accessToken ) && Boolean ( next ? . refreshToken ) && Number ( next ? . expiresIn ) > 0
this . assert ( 'authentication' , '刷新响应完整后原子替换会话' , response , valid ,
`accessTokenPresent= ${ Boolean ( next ? . accessToken ) } , refreshTokenPresent= ${ Boolean ( next ? . refreshToken ) } ` ,
{ scenario : 'session-refresh' , deploymentBlocker : true })
2026-08-25 16:40:48 +08:00
if ( ! valid ) return false
2026-09-29 19:48:34 +08:00
this . token = next . accessToken
this . refreshToken = next . refreshToken
this . expiresAt = Date . now () + Number ( next . expiresIn ) * 1000
2026-08-25 16:40:48 +08:00
return true
}
2026-08-08 10:11:04 +08:00
async findAvailablePhone ( module = 'customers' ) {
2026-09-02 12:33:26 +08:00
this . phoneSequence = Number ( this . phoneSequence || 0 ) + 1
2026-09-04 16:02:03 +08:00
for ( let attempt = 0 ; attempt < 100 ; attempt += 1 ) {
const seed = ` ${ this . marker } : ${ this . phoneSequence } : ${ attempt } `
let hash = 2166136261
for ( const character of seed ) {
hash ^= character . charCodeAt ( 0 )
hash = Math . imul ( hash , 16777619 ) >>> 0
}
const phone = `18 ${ String ( hash ). padStart ( 9 , '0' ). slice ( - 9 ) } `
const response = await this . request ( 'GET' ,
`/api/platform/v1/iam/tenants/administrator-phone-availability?phone= ${ encodeURIComponent ( phone ) } ` )
const available = response ? . http === 200 && String ( response ? . body ? . code ) === '200'
&& response ? . body ? . data === true
if ( ! available ) continue
this . addStep ( module , '生成本轮隔离测试联系电话' , response , true ,
`sequence= ${ this . phoneSequence } , attempts= ${ attempt + 1 } ` )
return phone
}
this . addStep ( module , '生成本轮隔离测试联系电话' , null , false ,
`sequence= ${ this . phoneSequence } , attempts=100;未找到可用手机号` , { deploymentBlocker : true })
throw new Error ( '未找到可用的隔离测试手机号' )
2026-08-08 10:11:04 +08:00
}
async runCustomerDimensionRules ( tenantId ) {
const module = 'customers' ; const scenario = 'customer-dimension-rules' ; const start = this . steps . length
const options = { scenario , deploymentBlocker : true }
const path = `/api/platform/v1/first-leg/customer-dimension-config?tenantId= ${ tenantId } `
const initialResponse = await this . request ( 'GET' , path )
const group = this . apiSuccess ( module , '新客户尺寸规则可查询' , initialResponse , `tenantId= ${ tenantId } ` , options )
const rules = Array . isArray ( group ? . rules ) ? group . rules : []
const orderedTypes = rules . map (( rule ) => rule . boxType )
this . assert ( module , '固定四箱规顺序完整且无重复' , initialResponse ,
rules . length === 4 && orderedTypes . join ( ',' ) === BOX_DEFAULTS . map (( item ) => item . boxType ). join ( ',' ) && new Set ( orderedTypes ). size === 4 ,
`count= ${ rules . length } , order= ${ orderedTypes . join ( ',' ) } ` , options )
const defaultsCorrect = BOX_DEFAULTS . every (( expected , index ) => {
const rule = rules [ index ]
return rule ? . boxType === expected . boxType
&& numberEquals ( rule . innerLengthCm , expected . dimensions [ 0 ])
&& numberEquals ( rule . innerWidthCm , expected . dimensions [ 1 ])
&& numberEquals ( rule . innerHeightCm , expected . dimensions [ 2 ])
&& numberEquals ( rule . edgeAdditionCm , '2.000' ) && rule . remark === expected . remark
})
this . assert ( module , '四箱规默认内径、加边和备注正确' , initialResponse , defaultsCorrect ,
defaultsCorrect ? 'defaults=matched' : '默认尺寸、edge 或备注不匹配' , options )
const identityCorrect = typeof group ? . tenantId === 'string' && group . tenantId === tenantId
&& rules . length === 4 && rules . every (( rule ) => typeof rule . id === 'string' && Number ( rule . version ) === 0 )
this . assert ( module , '客户 ID、规则 ID 为字符串且初始版本为0' , initialResponse , identityCorrect ,
`groupTenantIdType= ${ typeof group ? . tenantId } , rows= ${ rules . map (( r ) => ` ${ typeof r . id } /v ${ r . version } ` ). join ( ',' ) } ` , options )
const initialDerivedCorrect = rules . length === 4 && rules . every (( rule ) => {
const value = derived ( rule . innerLengthCm , rule . innerWidthCm , rule . innerHeightCm , rule . edgeAdditionCm )
return numberEquals ( rule . outerLengthCm , value . outerLength ) && numberEquals ( rule . outerWidthCm , value . outerWidth )
&& numberEquals ( rule . outerHeightCm , value . outerHeight ) && numberEquals ( rule . volumeCbm , value . volume )
})
this . assert ( module , '默认规则外径与 HALF_UP 三位 CBM 正确' , initialResponse , initialDerivedCorrect ,
`derivedRows= ${ rules . length } ` , options )
if ( rules . length === 4 ) {
const updatePayload = { rules : rules . map (( rule , index ) => ({
boxType : rule . boxType ,
innerLengthCm : Number (( Number ( rule . innerLengthCm ) + index + 0.125 ). toFixed ( 3 )),
innerWidthCm : Number (( Number ( rule . innerWidthCm ) + index + 0.250 ). toFixed ( 3 )),
innerHeightCm : Number (( Number ( rule . innerHeightCm ) + index + 0.375 ). toFixed ( 3 )),
edgeAdditionCm : Number (( 2.125 + index * 0.125 ). toFixed ( 3 )),
remark : ` ${ this . marker } - ${ rule . boxType } ` ,
version : Number ( rule . version ),
})) }
const allowed = [ 'boxType' , 'innerLengthCm' , 'innerWidthCm' , 'innerHeightCm' , 'edgeAdditionCm' , 'remark' , 'version' ]
this . assert ( module , 'PUT 每行只发送约定7字段并整组保存' , initialResponse ,
updatePayload . rules . length === 4 && updatePayload . rules . every (( row ) => Object . keys ( row ). join ( ',' ) === allowed . join ( ',' )),
`fields= ${ Object . keys ( updatePayload . rules [ 0 ]). join ( ',' ) } ` , options )
const updateResponse = await this . request ( 'PUT' , path , updatePayload )
const updatedGroup = this . apiSuccess ( module , '一次 PUT 保存四条三位小数规则' , updateResponse , 'rows=4' , options )
const updatedRules = Array . isArray ( updatedGroup ? . rules ) ? updatedGroup . rules : []
const updatedCorrect = updatedRules . length === 4 && updatePayload . rules . every (( expected , index ) => {
const actual = updatedRules [ index ]; const value = derived ( expected . innerLengthCm , expected . innerWidthCm , expected . innerHeightCm , expected . edgeAdditionCm )
return actual ? . boxType === expected . boxType && Number ( actual . version ) === expected . version + 1
&& numberEquals ( actual . innerLengthCm , expected . innerLengthCm ) && numberEquals ( actual . innerWidthCm , expected . innerWidthCm )
&& numberEquals ( actual . innerHeightCm , expected . innerHeightCm ) && numberEquals ( actual . edgeAdditionCm , expected . edgeAdditionCm )
&& numberEquals ( actual . outerLengthCm , value . outerLength ) && numberEquals ( actual . outerWidthCm , value . outerWidth )
&& numberEquals ( actual . outerHeightCm , value . outerHeight ) && numberEquals ( actual . volumeCbm , value . volume )
&& actual . remark === expected . remark
})
this . assert ( module , '修改后四行版本+1且派生外径/CBM正确' , updateResponse , updatedCorrect ,
`versions= ${ updatedRules . map (( r ) => r . version ). join ( ',' ) } ` , options )
const stalePayload = { rules : updatePayload . rules . map (( rule ) => ({
... rule ,
innerLengthCm : Number (( Number ( rule . innerLengthCm ) + 9 ). toFixed ( 3 )),
})) }
const staleResponse = await this . request ( 'PUT' , path , stalePayload )
this . expectConflict ( module , '旧 version 重放 PUT 返回冲突' , staleResponse , scenario )
const afterConflictResponse = await this . request ( 'GET' , path )
const afterConflict = this . apiSuccess ( module , '冲突后重新查询四条规则' , afterConflictResponse , '' , options )
const afterRules = Array . isArray ( afterConflict ? . rules ) ? afterConflict . rules : []
const stableSnapshot = ( rule ) => JSON . stringify ({
id : String ( rule . id ), boxType : rule . boxType ,
innerLengthCm : Number ( rule . innerLengthCm ), innerWidthCm : Number ( rule . innerWidthCm ),
innerHeightCm : Number ( rule . innerHeightCm ), edgeAdditionCm : Number ( rule . edgeAdditionCm ),
outerLengthCm : Number ( rule . outerLengthCm ), outerWidthCm : Number ( rule . outerWidthCm ),
outerHeightCm : Number ( rule . outerHeightCm ), volumeCbm : Number ( rule . volumeCbm ),
remark : rule . remark ?? null , version : Number ( rule . version ), updatedAt : rule . updatedAt ?? null ,
})
const atomic = afterRules . length === 4 && updatedRules . length === 4
&& afterRules . every (( rule , index ) => stableSnapshot ( rule ) === stableSnapshot ( updatedRules [ index ]))
this . assert ( module , '冲突请求未造成四行部分更新' , afterConflictResponse , atomic ,
`unchangedRows= ${ atomic ? 4 : 0 } ` , options )
const currentByType = new Map ( afterRules . map (( rule ) => [ rule . boxType , rule ]))
const restorePayload = { rules : rules . map (( original ) => ({
boxType : original . boxType ,
innerLengthCm : Number ( original . innerLengthCm ),
innerWidthCm : Number ( original . innerWidthCm ),
innerHeightCm : Number ( original . innerHeightCm ),
edgeAdditionCm : Number ( original . edgeAdditionCm ),
remark : original . remark ?? null ,
version : Number ( currentByType . get ( original . boxType ) ? . version ),
})) }
const restorable = restorePayload . rules . length === 4
&& restorePayload . rules . every (( rule ) => Number . isInteger ( rule . version ) && rule . version >= 0 )
if ( restorable ) {
const restoreResponse = await this . request ( 'PUT' , path , restorePayload )
const restoredGroup = this . apiSuccess ( module , '恢复本轮修改前的四箱规配置' , restoreResponse ,
'restore=original-values' , options )
const restoredRules = Array . isArray ( restoredGroup ? . rules ) ? restoredGroup . rules : []
const restored = restoredRules . length === 4 && rules . every (( original , index ) => {
const actual = restoredRules [ index ]
return actual ? . boxType === original . boxType
&& numberEquals ( actual . innerLengthCm , original . innerLengthCm )
&& numberEquals ( actual . innerWidthCm , original . innerWidthCm )
&& numberEquals ( actual . innerHeightCm , original . innerHeightCm )
&& numberEquals ( actual . edgeAdditionCm , original . edgeAdditionCm )
&& actual . remark === original . remark
&& Number ( actual . version ) === restorePayload . rules [ index ]. version + 1
})
this . assert ( module , '尺寸规则恢复后回显与初始值一致' , restoreResponse , restored ,
`restoredRows= ${ restored ? 4 : 0 } ` , options )
} else {
this . assert ( module , '恢复本轮修改前的四箱规配置' , afterConflictResponse , false ,
'冲突后未返回完整四行及有效版本,无法安全恢复' , options )
}
} else {
this . assert ( module , '尺寸规则编辑与并发冲突场景' , initialResponse , false , '默认规则不是完整四条,禁止降级跳过' , options )
}
const steps = this . steps . slice ( start )
this . scenarios . push ({ name : scenario , module , success : steps . every (( step ) => step . passed ), deploymentBlocked : steps . some (( step ) => step . deploymentBlocker ), passed : steps . filter (( step ) => step . passed ). length , failed : steps . filter (( step ) => ! step . passed ). length })
}
async runCustomers () {
2026-10-21 21:30:24 +08:00
if ( this . options . fixedCustomerId ) {
this . context . customerId = this . context . merchantQualificationFixture ? . tenantId
this . addCoverageGap ( 'customers' , 'customer-lifecycle' , '按用户要求排除建客及归档生命周期' ,
'复用固定客户;不改其资质、状态和账户,不把该生命周期列为通过' , 'not-implemented' , { blocking : false })
return
}
2026-08-08 10:11:04 +08:00
const module = 'customers' ; const phone = await this . findAvailablePhone ( module ); const suffix = this . marker . slice ( - 12 )
const payload = {
2026-09-23 17:47:50 +08:00
tenantName : `后台客户验收- ${ suffix } ` , customerNickname : `后台客户- ${ suffix } ` , legalName : `后台客户验收有限公司- ${ suffix } ` ,
2026-08-08 10:11:04 +08:00
legalRepresentative : '接口验收法人' , contactName : '接口验收联系人' , contactPhone : phone ,
contactEmail : `admin-api- ${ suffix . toLowerCase () } @example.com` , servicePackage : 'API验收套餐' ,
contactWechat : `api_ ${ suffix . toLowerCase () } ` , salesOwnerName : '接口验收销售' , paymentTermDays : 30 ,
enterpriseType : 'LIMITED_COMPANY' , establishedDate : '2020-01-02' , registeredCapital : 5000000 ,
registeredCapitalCurrency : 'CNY' , registeredAddress : '深圳市南山区接口验收地址' , internalRemark : this . marker ,
}
const createResponse = await this . request ( 'POST' , '/api/platform/v1/iam/tenants' , payload )
const createData = this . apiSuccess ( module , '创建客户' , createResponse )
if ( ! createData ) throw new Error ( '创建客户失败,后续客户模块无法继续' )
const customer = createData . tenant || createData . customer || createData
const tenantId = String ( customer . id || createData . tenantId || '' )
if ( ! tenantId ) throw new Error ( '创建响应未返回客户 ID' )
this . context . customerId = tenantId
this . createdResources . customer = { tenantId , marker : this . marker , contactPhone : phone }
this . assert ( module , '创建响应客户字段完整' , createResponse , hasFields ( customer , CUSTOMER_FIELDS ), `missing= ${ CUSTOMER_FIELDS . filter (( f ) => ! own ( customer , f )). join ( ',' ) || 'none' } ` )
this . assert ( module , '新客户基础字段正确' , createResponse , customer . tenantName === payload . tenantName && customer . contactPhone === phone && customer . status === 'PROFILE_REQUIRED' , `tenantId= ${ tenantId } , status= ${ customer . status } ` )
const ownerProvisioningOptions = { scenario : 'customer-owner-provisioning' , deploymentBlocker : true }
this . assert ( module , '创建客户生成初始密码' , createResponse ,
typeof createData . temporaryPassword === 'string' && createData . temporaryPassword . length >= 8
&& /[A-Za-z]/ . test ( createData . temporaryPassword ) && /\d/ . test ( createData . temporaryPassword ),
`generated= ${ typeof createData . temporaryPassword === 'string' } , length= ${ createData . temporaryPassword ? . length || 0 } ` ,
ownerProvisioningOptions )
2026-09-02 13:03:03 +08:00
const administratorUsername = String ( createData . administratorUsername || '' )
this . assert ( module , '管理员用户名作为独立登录标识返回' , createResponse ,
administratorUsername . length > 0 ,
`present= ${ administratorUsername . length > 0 } ` ,
2026-08-08 10:11:04 +08:00
ownerProvisioningOptions )
this . assert ( module , '待完善客户的审核字段保持为空' , createResponse ,
customer . unifiedSocialCreditCode == null && customer . approvedAt == null ,
`creditCode= ${ customer . unifiedSocialCreditCode ?? 'omitted' } , approvedAt= ${ customer . approvedAt ?? 'omitted' } ` )
2026-09-02 13:03:03 +08:00
const dimensionInitializationPath = `/api/platform/v1/iam/tenants/ ${ tenantId } /dimension-initialization`
let dimensionInitializationResponse = null
let dimensionInitialization = null
for ( let attempt = 0 ; attempt < 30 ; attempt += 1 ) {
dimensionInitializationResponse = await this . request ( 'GET' , dimensionInitializationPath )
dimensionInitialization = dimensionInitializationResponse . http === 200
&& String ( dimensionInitializationResponse . body ? . code ) === '200'
? dimensionInitializationResponse . body ? . data : null
if ( dimensionInitialization ? . status === 'READY' ) break
if ( attempt < 29 ) await new Promise (( resolve ) => setTimeout ( resolve , 1_000 ))
}
this . assert ( module , '客户默认尺寸异步初始化完成' , dimensionInitializationResponse ,
dimensionInitialization ? . status === 'READY' ,
`status= ${ dimensionInitialization ? . status || 'missing' } ` , ownerProvisioningOptions )
if ( dimensionInitialization ? . status === 'READY' ) await this . runCustomerDimensionRules ( tenantId )
2026-08-08 10:11:04 +08:00
const listResponse = await this . request ( 'GET' , `/api/platform/v1/iam/tenants?page=1&size=20&keyword= ${ encodeURIComponent ( payload . tenantName ) } ` )
const page = this . apiSuccess ( module , '查询客户列表' , listResponse ); const listed = page ? . records ? . find (( item ) => String ( item . id ) === tenantId )
this . assert ( module , '列表包含新客户且字段完整' , listResponse , listed && hasFields ( listed , CUSTOMER_FIELDS ), `found= ${ Boolean ( listed ) } , missing= ${ listed ? CUSTOMER_FIELDS . filter (( f ) => ! own ( listed , f )). join ( ',' ) || 'none' : 'all' } ` )
const detailPath = `/api/platform/v1/iam/tenants/ ${ tenantId } `
const detailResponse = await this . request ( 'GET' , detailPath ); const detail = this . apiSuccess ( module , '查询客户详情' , detailResponse )
this . assert ( module , '客户详情基本信息正确' , detailResponse , detail && detail . tenantName === payload . tenantName && detail . contactPhone === phone && detail . internalRemark === payload . internalRemark , `tenantId= ${ tenantId } ` )
2026-09-23 17:47:50 +08:00
const editedPayload = { ... payload , tenantName : ` ${ payload . tenantName } -已编辑` , customerNickname : ` ${ payload . customerNickname } -已编辑` , contactName : '接口验收联系人-已编辑' , contactEmail : `edited- ${ payload . contactEmail } ` , paymentTermDays : 45 , internalRemark : ` ${ this . marker } -EDITED` }
2026-08-08 10:11:04 +08:00
const editResponse = await this . request ( 'PUT' , detailPath , editedPayload ); const edited = this . apiSuccess ( module , '编辑客户' , editResponse )
this . assert ( module , '客户编辑正确回显' , editResponse , edited ? . tenantName === editedPayload . tenantName && edited ? . contactEmail === editedPayload . contactEmail && Number ( edited ? . paymentTermDays ) === 45 , `tenantId= ${ tenantId } ` )
const persistedResponse = await this . request ( 'GET' , detailPath ); const persisted = this . apiSuccess ( module , '回查客户编辑结果' , persistedResponse )
this . assert ( module , '客户编辑已持久化' , persistedResponse , persisted ? . tenantName === editedPayload . tenantName && persisted ? . internalRemark === editedPayload . internalRemark , `tenantId= ${ tenantId } ` )
2026-08-13 11:57:33 +08:00
let customerAuditPage = null
let customerAuditResponse = null
for ( let attempt = 0 ; attempt < 8 ; attempt += 1 ) {
customerAuditResponse = await this . request ( 'GET' , `/api/platform/v1/audit/logs?accountId= ${ tenantId } &eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId= ${ tenantId } &page=1&size=20` )
customerAuditPage = customerAuditResponse . http === 200 && String ( customerAuditResponse . body ? . code ) === '200' ? customerAuditResponse . body ? . data : null
if ( customerAuditPage ? . records ? . some (( item ) => item . eventType === 'PLATFORM_TENANT_UPDATE' && String ( item . accountId ) === tenantId )) break
await new Promise (( resolve ) => setTimeout ( resolve , 500 ))
}
const customerAudit = this . apiSuccess ( module , '按客户查询操作日志' , customerAuditResponse )
const customerAuditRow = customerAudit ? . records ? . find (( item ) => item . eventType === 'PLATFORM_TENANT_UPDATE' && String ( item . accountId ) === tenantId )
2026-08-13 16:58:17 +08:00
let auditDiagnostic = 'event=missing'
if ( ! customerAuditRow ) {
const unscopedResponse = await this . request ( 'GET' , `/api/platform/v1/audit/logs?eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId= ${ tenantId } &page=1&size=20` )
const unscopedPage = unscopedResponse . http === 200 && String ( unscopedResponse . body ? . code ) === '200'
? unscopedResponse . body ? . data : null
const misplaced = unscopedPage ? . records ? . find (( item ) => item . eventType === 'PLATFORM_TENANT_UPDATE' )
auditDiagnostic = misplaced
? `eventAccountId= ${ misplaced . accountId ?? 'missing' } , expectedAccountId= ${ tenantId } `
: `event=not-delivered, editRequestId= ${ editResponse . requestId || 'missing' } , editTraceId= ${ editResponse . traceId || 'missing' } `
}
this . assert ( module , '客户操作日志包含本轮编辑事件' , customerAuditResponse , Boolean ( customerAuditRow ),
`tenantId= ${ tenantId } , ${ customerAuditRow ? `event= ${ customerAuditRow . eventType } ` : auditDiagnostic } ` )
2026-08-13 11:57:33 +08:00
if ( customerAuditRow ? . id ) {
const auditDetailResponse = await this . request ( 'GET' , `/api/platform/v1/audit/logs/ ${ customerAuditRow . id } ` )
const auditDetail = this . apiSuccess ( module , '查询客户操作日志详情' , auditDetailResponse )
const snapshotsAreJson = [ 'beforeData' , 'afterData' ]. every (( field ) => {
if ( typeof auditDetail ? .[ field ] !== 'string' ) return false
try { JSON . parse ( auditDetail [ field ]); return true } catch { return false }
})
const snapshotText = ` ${ auditDetail ? . beforeData || '' }${ auditDetail ? . afterData || '' } `
const snapshotsAreRedacted = ! /(password|authorization|accessToken|refreshToken)\s*[\"']?\s*:/i . test ( snapshotText )
this . assert ( module , '操作日志详情返回脱敏 JSON 快照' , auditDetailResponse ,
snapshotsAreJson && snapshotsAreRedacted && String ( auditDetail . accountId ) === tenantId && String ( auditDetail . resourceId ) === tenantId ,
`accountId= ${ auditDetail ? . accountId ?? 'missing' } , resourceId= ${ auditDetail ? . resourceId ?? 'missing' } , snapshotsAreJson= ${ snapshotsAreJson } , snapshotsAreRedacted= ${ snapshotsAreRedacted } ` )
}
const qualificationPackage = new FormData ()
const qualificationImage = Uint8Array . from ( Buffer . from ( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' , 'base64' ))
qualificationPackage . append ( 'businessLicense' , new Blob ([ qualificationImage ], { type : 'image/png' }), `business-license- ${ suffix } .png` )
qualificationPackage . append ( 'legalPersonId' , new Blob ([ qualificationImage ], { type : 'image/png' }), `legal-person-id- ${ suffix } .png` )
2026-08-13 16:58:17 +08:00
qualificationPackage . append ( 'unifiedSocialCreditCode' , `91440301 ${ suffix . replace ( /[^0-9A-Z]/gi , '' ). toUpperCase (). padStart ( 10 , '0' ). slice ( - 10 ) } ` )
2026-08-13 11:57:33 +08:00
const packageResponse = await this . requestWithBody ( 'POST' , `/api/platform/v1/files/qualifications/ ${ tenantId } /required-package` , qualificationPackage )
this . apiSuccess ( module , '上传审核所需客户资质包' , packageResponse , `tenantId= ${ tenantId } ` )
2026-08-08 10:11:04 +08:00
const qualificationResponse = await this . request ( 'GET' , ` ${ detailPath } /qualifications` ); const qualifications = this . apiSuccess ( module , '业务概况查询资质' , qualificationResponse )
2026-08-13 11:57:33 +08:00
const requiredQualifications = Array . isArray ( qualifications )
? qualifications . filter (( item ) => [ 'BUSINESS_LICENSE' , 'LEGAL_PERSON_ID' ]. includes ( item . qualificationType )) : []
this . assert ( module , '审核资质列表包含营业执照和法人身份证' , qualificationResponse ,
requiredQualifications . length === 2 && requiredQualifications . every (( item ) => typeof item . documentUrl === 'string' && item . documentUrl . length > 0 ),
`types= ${ requiredQualifications . map (( item ) => item . qualificationType ). join ( ',' ) || 'missing' } ` )
for ( const qualification of requiredQualifications ) {
const fileName = decodeURIComponent ( new URL ( qualification . documentUrl , 'http://acceptance.local' ). pathname . split ( '/' ). pop () || '' )
const downloadResponse = await this . requestBinary ( 'GET' , `/api/platform/v1/files/qualifications/ ${ tenantId } / ${ encodeURIComponent ( fileName ) } ` )
this . assert ( module , `审核人员可下载 ${ qualification . qualificationType } ` , downloadResponse ,
downloadResponse . http === 200 && downloadResponse . bytes . length > 0 && /^image\/|application\/pdf/ . test ( downloadResponse . contentType ),
`file= ${ fileName } , bytes= ${ downloadResponse . bytes . length } , contentType= ${ downloadResponse . contentType || 'missing' } ` )
}
2026-08-08 10:11:04 +08:00
const memberResponse = await this . request ( 'GET' , ` ${ detailPath } /members` ); const members = this . apiSuccess ( module , '业务概况查询成员' , memberResponse )
const owner = members ? . find ? .(( item ) => item . memberType === 'OWNER' )
2026-09-02 13:03:03 +08:00
this . assert ( module , '独立用户名账号是企业管理员且联系电话不承担用户名约束' , memberResponse ,
owner ? . username === administratorUsername ,
`ownerFound= ${ Boolean ( owner ) } , matches= ${ owner ? . username === administratorUsername } ` ,
2026-08-08 10:11:04 +08:00
ownerProvisioningOptions )
const countResponse = await this . request ( 'GET' , `/api/platform/v1/orders/first-leg/tenant-counts?tenantIds= ${ tenantId } ` ); const counts = this . apiSuccess ( module , '业务概况查询运单数量' , countResponse ); const count = counts ? . find ? .(( item ) => String ( item . tenantId ) === tenantId )
this . assert ( module , '业务概况返回当前客户运单数' , countResponse , count && Number . isFinite ( Number ( count . orderCount )), `orderCount= ${ count ? . orderCount ?? 'missing' } ` )
const ordersResponse = await this . request ( 'GET' , `/api/platform/v1/orders/first-leg?tenantId= ${ tenantId } &page=1&size=10` ); const orders = this . apiSuccess ( module , '查询客户运单记录' , ordersResponse )
this . assert ( module , '运单记录分页结构正确' , ordersResponse , Array . isArray ( orders ? . records ) && Number . isFinite ( Number ( orders ? . total )), `records= ${ orders ? . records ? . length ?? 'n/a' } , total= ${ orders ? . total ?? 'n/a' } ` )
const archiveOptions = { scenario : 'customer-draft-archive' , deploymentBlocker : true }
const archiveResponse = await this . request ( 'POST' , ` ${ detailPath } /archive` )
const archived = this . apiSuccess ( module , '归档测试客户' , archiveResponse , `tenantId= ${ tenantId } ` , archiveOptions )
if ( archived ) {
2026-08-14 22:59:57 +08:00
this . assert ( module , '测试客户归档后处于 ARCHIVED 状态' , archiveResponse ,
archived . status === 'ARCHIVED' , `status= ${ archived . status ?? 'missing' } ` , archiveOptions )
2026-08-08 10:11:04 +08:00
}
}
2026-08-08 13:32:20 +08:00
async prepareInboundMeasurementFixture () {
const originalMarker = this . marker
const fixtureMarker = ` ${ originalMarker } -MOJIA`
const createdKeys = [ 'orders' , 'ocrInbound' , 'testInbound' ]
const contextKeys = [ 'inboundWeightOcr' , 'ocrInbound' , 'testInbound' , 'createdInbound' ]
const createdSnapshot = new Map ( createdKeys . map (( key ) => [ key , {
present : Object . hasOwn ( this . createdResources , key ), value : this . createdResources [ key ],
}]))
const contextSnapshot = new Map ( contextKeys . map (( key ) => [ key , {
present : Object . hasOwn ( this . context , key ), value : this . context [ key ],
}]))
this . marker = fixtureMarker
const extension = await import ( new URL ( './admin-test-env-modules/orders.mjs' , import . meta . url ))
await this . executeModule ( 'orders-inbound-measurement-fixture' , () => extension . run ( this ))
const fixture = this . createdResources . orders
this . createdResources . orchestrationFixtures = {
...( this . createdResources . orchestrationFixtures || {}),
inboundMeasurement : fixture ? {
marker : fixtureMarker ,
tenantId : fixture . tenantId ,
orderId : fixture . orderId ,
inboundId : fixture . inboundId ,
} : { marker : fixtureMarker , failed : true },
}
return () => {
this . marker = originalMarker
for ( const [ key , snapshot ] of createdSnapshot ) {
if ( snapshot . present ) this . createdResources [ key ] = snapshot . value
else delete this . createdResources [ key ]
}
for ( const [ key , snapshot ] of contextSnapshot ) {
if ( snapshot . present ) this . context [ key ] = snapshot . value
else delete this . context [ key ]
}
}
}
2026-08-08 21:34:39 +08:00
async preparePhase3ContainerFixture () {
const originalMarker = this . marker
2026-08-13 19:19:29 +08:00
// Isolate order idempotency, then publish the fixture under the run marker.
const fixtureMarker = ` ${ originalMarker } -CAB`
2026-08-08 21:34:39 +08:00
const createdKeys = [ 'orders' , 'ocrInbound' , 'testInbound' , 'inboundMeasurement' ]
const contextKeys = [ 'inboundWeightOcr' , 'ocrInbound' , 'testInbound' , 'createdInbound' ]
const createdSnapshot = new Map ( createdKeys . map (( key ) => [ key , {
present : Object . hasOwn ( this . createdResources , key ), value : this . createdResources [ key ],
}]))
const contextSnapshot = new Map ( contextKeys . map (( key ) => [ key , {
present : Object . hasOwn ( this . context , key ), value : this . context [ key ],
}]))
this . marker = fixtureMarker
const orders = await import ( new URL ( './admin-test-env-modules/orders.mjs' , import . meta . url ))
await this . executeModule ( 'orders-phase3-container-fixture' , () => orders . run ( this ))
const measurement = await import ( new URL ( './admin-test-env-modules/inbound-measurement.mjs' , import . meta . url ))
await this . executeModule ( 'inbound-measurement-phase3-container-fixture' , () => measurement . run ( this ))
2026-08-13 14:37:51 +08:00
const fixture = this . createdResources . inboundMeasurement
if ( ! fixture ? . inboundId || fixture . finalStatus !== 'APPROVED' || fixture . processStatus !== 'WAIT_SPLIT' ) {
throw new Error ( `独立柜次测量 fixture 未进入 WAIT_SPLIT,禁止回退使用 WAIT_PRICING 订单: processStatus= ${ fixture ? . processStatus || 'missing' } , measurementStatus= ${ fixture ? . finalStatus || 'missing' } ` )
}
2026-08-08 21:34:39 +08:00
this . createdResources . orchestrationFixtures = {
...( this . createdResources . orchestrationFixtures || {}),
phase3Container : fixture ? {
2026-08-13 19:19:29 +08:00
marker : originalMarker ,
sourceMarker : fixtureMarker ,
2026-08-08 21:34:39 +08:00
tenantId : fixture . tenantId ,
orderId : fixture . orderId || this . createdResources . orders ? . orderId ,
inboundId : fixture . inboundId ,
} : { marker : fixtureMarker , failed : true },
}
return () => {
this . marker = originalMarker
for ( const [ key , snapshot ] of createdSnapshot ) {
if ( snapshot . present ) this . createdResources [ key ] = snapshot . value
else delete this . createdResources [ key ]
}
for ( const [ key , snapshot ] of contextSnapshot ) {
if ( snapshot . present ) this . context [ key ] = snapshot . value
else delete this . context [ key ]
}
}
}
2026-08-28 22:39:18 +08:00
async preparePhase3LoadingFixture ( suffix = '' , requireSharedOverflow = true , taxModes = null ) {
2026-08-12 10:59:39 +08:00
const originalMarker = this . marker
2026-08-24 19:58:35 +08:00
const fixtureMarker = ` ${ originalMarker } -LOAD ${ suffix } `
2026-08-12 10:59:39 +08:00
const createdKeys = [ 'orders' , 'ocrInbound' , 'testInbound' , 'inboundMeasurement' ]
2026-08-12 13:24:17 +08:00
const contextKeys = [ 'inboundWeightOcr' , 'ocrInbound' , 'testInbound' , 'createdInbound' ,
2026-08-25 13:06:09 +08:00
'phase3LoadingRequireSharedOverflow' , 'phase3LoadingIncrementalPackageCount' ,
2026-09-10 20:47:15 +08:00
'phase3LoadingIncrementalDetailCount' , 'orderProfile' ]
2026-08-12 10:59:39 +08:00
const createdSnapshot = new Map ( createdKeys . map (( key ) => [ key , {
present : Object . hasOwn ( this . createdResources , key ), value : this . createdResources [ key ],
}]))
const contextSnapshot = new Map ( contextKeys . map (( key ) => [ key , {
present : Object . hasOwn ( this . context , key ), value : this . context [ key ],
}]))
this . marker = fixtureMarker
2026-08-28 22:39:18 +08:00
if ( taxModes ? . primary ) {
this . context . orderProfile = {
...( this . context . orderProfile || {}), taxMode : taxModes . primary ,
}
}
2026-08-12 10:59:39 +08:00
const orders = await import ( new URL ( './admin-test-env-modules/orders.mjs' , import . meta . url ))
2026-09-14 17:22:09 +08:00
// Keep the primary one-click producer deliberately multi-pallet. This is the regression
// fixture for the real business shape where one inbound has more than one sealed DP pallet:
// every pallet must remain independently assignable while the handover stays indivisible.
// The separate STANDARD incremental fixture below continues to cover SKIP/PARTIAL mutations.
2026-09-02 17:57:35 +08:00
const primaryDropship = this . context . orderFulfillmentType === 'ONE_CLICK_DROPSHIP'
const hadWaitSplitVariant = Object . hasOwn ( this . context , 'waitSplitVariant' )
const savedWaitSplitVariant = this . context . waitSplitVariant
2026-09-14 17:22:09 +08:00
const primaryDropshipVariant = {
2026-10-23 04:48:02 +08:00
// 三箱PDA换标、一箱客户换标、一箱转运,共用同一订单,不另造订单或主档。
...( savedWaitSplitVariant || {}), skuCount : 1 , packageCountPerSku : 5 ,
packageCounts : [ 5 ], palletCount : 2 ,
2026-09-14 17:22:09 +08:00
}
2026-09-02 17:57:35 +08:00
if ( primaryDropship ) {
2026-09-14 17:22:09 +08:00
this . context . waitSplitVariant = primaryDropshipVariant
2026-09-02 17:57:35 +08:00
}
try {
await this . executeModule ( 'orders-phase3-loading-fixture' , () => orders . run ( this ))
} finally {
if ( hadWaitSplitVariant ) this . context . waitSplitVariant = savedWaitSplitVariant
else delete this . context . waitSplitVariant
}
2026-08-12 13:24:17 +08:00
const fixtureInbound = this . createdResources . orders
const warehouseId = String ( fixtureInbound ? . receivingWarehouseId || '' )
this . assert ( 'phase3-loading-fixture' , 'A0 隔离使用本轮订单收货仓' , null ,
Boolean ( warehouseId ), `warehouseId= ${ warehouseId || 'missing' } ` ,
{ scenario : 'phase3-loading-a0-fixture' , deploymentBlocker : true })
2026-09-14 17:22:09 +08:00
const requiredPalletCount = primaryDropship ? primaryDropshipVariant . palletCount
: Math . max ( 1 , Number ( this . context ? . waitSplitVariant ? . palletCount || 1 ))
2026-09-01 10:18:19 +08:00
const isolation = requireSharedOverflow
? await this . disableAvailableLocationsForA0 ( warehouseId , requiredPalletCount )
: { forced : false , disabled : [] }
2026-09-02 03:51:07 +08:00
this . context . phase3LoadingRequireSharedOverflow = requireSharedOverflow
2026-09-14 17:22:09 +08:00
if ( primaryDropship ) this . context . waitSplitVariant = primaryDropshipVariant
2026-08-12 13:24:17 +08:00
try {
const measurement = await import ( new URL ( './admin-test-env-modules/inbound-measurement.mjs' , import . meta . url ))
await this . executeModule ( 'inbound-measurement-phase3-loading-fixture' , () => measurement . run ( this ))
} finally {
2026-08-28 09:19:18 +08:00
if ( requireSharedOverflow ) {
2026-09-01 10:18:19 +08:00
await this . restoreAvailableLocationsAfterA0 ( warehouseId , isolation )
2026-08-28 09:19:18 +08:00
}
2026-09-14 17:22:09 +08:00
if ( hadWaitSplitVariant ) this . context . waitSplitVariant = savedWaitSplitVariant
else delete this . context . waitSplitVariant
2026-08-12 13:24:17 +08:00
}
2026-08-12 10:59:39 +08:00
const fixture = this . createdResources . inboundMeasurement || this . createdResources . orders
2026-08-24 11:48:16 +08:00
const fixtureOrder = this . createdResources . orders || {}
2026-08-12 10:59:39 +08:00
const loadingFixture = fixture ? {
marker : originalMarker ,
sourceMarker : fixtureMarker ,
tenantId : fixture . tenantId ,
orderId : fixture . orderId || this . createdResources . orders ? . orderId ,
inboundId : fixture . inboundId ,
2026-08-24 22:14:41 +08:00
boxCode : fixture . boxCode ,
2026-09-09 13:20:11 +08:00
sealedCartonMarks : Array . isArray ( fixture . sealedCartonMarks ) ? fixture . sealedCartonMarks : [],
2026-09-09 19:25:13 +08:00
sealedSourcePalletNos : Array . isArray ( fixture . sealedSourcePalletNos ) ? fixture . sealedSourcePalletNos : [],
2026-08-25 09:16:00 +08:00
fulfillmentType : fixtureOrder . fulfillmentType || 'STANDARD' ,
2026-08-25 09:23:28 +08:00
skuCodes : Array . isArray ( fixtureOrder . skuCodes ) ? fixtureOrder . skuCodes : [],
skuLines : Array . isArray ( fixtureOrder . skuLines ) ? fixtureOrder . skuLines : [],
2026-08-24 11:48:16 +08:00
customerRemark : fixtureOrder . customerRemark ,
internalRemark : fixtureOrder . internalRemark ,
cargoRemark : fixtureOrder . cargoRemark ,
2026-08-28 22:39:18 +08:00
taxMode : fixtureOrder . taxMode || null ,
2026-09-09 00:25:34 +08:00
finalStatus : fixture . finalStatus ,
processStatus : fixture . processStatus ,
2026-09-02 19:29:23 +08:00
requireInventoryMutation : requireSharedOverflow ,
2026-08-12 10:59:39 +08:00
} : { marker : originalMarker , sourceMarker : fixtureMarker , failed : true }
2026-08-24 19:58:35 +08:00
const incrementalMarker = ` ${ originalMarker } -LOAD ${ suffix } -INC`
2026-08-17 15:41:42 +08:00
this . marker = incrementalMarker
this . context . phase3LoadingRequireSharedOverflow = false
this . context . phase3LoadingIncrementalPackageCount = 2
2026-08-25 13:06:09 +08:00
this . context . phase3LoadingIncrementalDetailCount = 4
2026-09-02 17:57:35 +08:00
// orders.mjs derives the actual SKU/package shape from waitSplitVariant. Override that
// shape for this mutation fixture as well, otherwise a one-carton dropship primary leaks
// into the STANDARD incremental order and removes the SKIP/PARTIAL coverage data.
const hadIncrementalWaitSplitVariant = Object . hasOwn ( this . context , 'waitSplitVariant' )
const savedIncrementalWaitSplitVariant = this . context . waitSplitVariant
this . context . waitSplitVariant = {
...( savedIncrementalWaitSplitVariant || {}), skuCount : 4 , packageCountPerSku : 2 ,
packageCounts : [ 2 , 2 , 2 , 2 ],
}
2026-09-01 23:20:34 +08:00
this . context . orderProfile = {
...( this . context . orderProfile || {}),
// The incremental order exercises partial/SKIP/PARTIAL mutations. Keep it STANDARD so a
// ONE_CLICK_DROPSHIP primary order remains a physically indivisible whole-pallet handover.
fulfillmentType : 'STANDARD' ,
...( taxModes ? . incremental ? { taxMode : taxModes . incremental } : {}),
2026-08-28 22:39:18 +08:00
}
2026-08-17 15:41:42 +08:00
const measurement = await import ( new URL ( './admin-test-env-modules/inbound-measurement.mjs' , import . meta . url ))
2026-09-02 17:57:35 +08:00
// orders.mjs reads this top-level context field rather than orderProfile. Switch it as well
// so this mutation fixture is genuinely STANDARD, then restore the producer variant before
// the enclosing phase-four workflow records its ONE_CLICK_DROPSHIP facts.
const hadOrderFulfillmentType = Object . hasOwn ( this . context , 'orderFulfillmentType' )
const savedOrderFulfillmentType = this . context . orderFulfillmentType
this . context . orderFulfillmentType = 'STANDARD'
try {
await this . executeModule ( 'orders-phase3-loading-incremental-fixture' , () => orders . run ( this ))
await this . executeModule ( 'inbound-measurement-phase3-loading-incremental-fixture' , () => measurement . run ( this ))
} finally {
if ( hadOrderFulfillmentType ) this . context . orderFulfillmentType = savedOrderFulfillmentType
else delete this . context . orderFulfillmentType
if ( hadIncrementalWaitSplitVariant ) this . context . waitSplitVariant = savedIncrementalWaitSplitVariant
else delete this . context . waitSplitVariant
}
2026-08-17 15:41:42 +08:00
const incrementalSource = this . createdResources . inboundMeasurement || this . createdResources . orders
const incrementalFixture = incrementalSource ? {
marker : originalMarker ,
sourceMarker : incrementalMarker ,
tenantId : incrementalSource . tenantId ,
orderId : incrementalSource . orderId ,
inboundId : incrementalSource . inboundId ,
2026-08-24 22:14:41 +08:00
boxCode : incrementalSource . boxCode ,
2026-08-25 09:16:00 +08:00
fulfillmentType : this . createdResources . orders ? . fulfillmentType || 'STANDARD' ,
2026-08-28 22:39:18 +08:00
taxMode : this . createdResources . orders ? . taxMode || null ,
2026-08-25 09:23:28 +08:00
skuCodes : Array . isArray ( this . createdResources . orders ? . skuCodes )
? this . createdResources . orders . skuCodes : [],
skuLines : Array . isArray ( this . createdResources . orders ? . skuLines )
? this . createdResources . orders . skuLines : [],
2026-08-17 15:41:42 +08:00
} : { marker : originalMarker , failed : true }
2026-08-12 10:59:39 +08:00
this . createdResources . orchestrationFixtures = {
...( this . createdResources . orchestrationFixtures || {}),
phase3Loading : loadingFixture ,
2026-08-17 15:41:42 +08:00
phase3LoadingIncremental : incrementalFixture ,
2026-08-12 10:59:39 +08:00
inboundMeasurement : loadingFixture ,
}
this . marker = originalMarker
return () => {
this . marker = originalMarker
for ( const [ key , snapshot ] of createdSnapshot ) {
if ( snapshot . present ) this . createdResources [ key ] = snapshot . value
else delete this . createdResources [ key ]
}
for ( const [ key , snapshot ] of contextSnapshot ) {
if ( snapshot . present ) this . context [ key ] = snapshot . value
else delete this . context [ key ]
}
}
}
2026-08-25 15:27:26 +08:00
async runPhase4ProducerVariant ( label , destinationType , fulfillmentType = 'STANDARD' , pickupRequired = false ) {
2026-10-23 04:48:02 +08:00
const shippingRequired = pickupRequired || label === 'SECONDARY'
2026-08-24 19:58:35 +08:00
const matrixMarker = this . marker
2026-09-10 21:18:52 +08:00
const attempt = ( this . phase4ProducerAttempts . get ( label ) || 0 ) + 1
this . phase4ProducerAttempts . set ( label , attempt )
const variantMarker = ` ${ matrixMarker } -P4- ${ label }${ attempt === 1 ? '' : `-R ${ attempt } ` } `
2026-10-02 20:14:17 +08:00
const contextKeys = [ 'orderDestinationType' , 'orderFulfillmentType' , 'phase4ProducerMatrixMarker' , 'waitSplitRoute' ]
2026-08-25 15:27:26 +08:00
const createdKeys = [
2026-09-09 01:09:31 +08:00
'phase3Loading' , 'phase3LoadingCompletedProducer' , 'phase3ContainerDocuments' , 'orchestrationFixtures' , 'oceanBooking' ,
2026-08-25 15:27:26 +08:00
'phase3CustomsSupplier' , 'phase3Customs' , 'serviceCenterFlow' , 'portSchedule' ,
'phase3OutboundDispatch' , 'phase3CarrierScheduleQuery' , 'phase4CarrierPickupReady' ,
]
2026-08-24 19:58:35 +08:00
const contextSnapshot = new Map ( contextKeys . map (( key ) => [ key , {
present : Object . hasOwn ( this . context , key ), value : this . context [ key ],
}]))
const createdSnapshot = new Map ( createdKeys . map (( key ) => [ key , {
present : Object . hasOwn ( this . createdResources , key ), value : this . createdResources [ key ],
}]))
let restoreFixture = null
2026-08-25 18:32:12 +08:00
// Each producer variant must create and advance its own loading, outbound and carrier facts.
// Keeping the outer run's objects here can make a later container write PICKUP_READY nodes
// to an earlier carrier task while still reporting the variant as ready.
for ( const key of createdKeys ) delete this . createdResources [ key ]
2026-08-24 19:58:35 +08:00
this . marker = variantMarker
this . context . orderDestinationType = destinationType
2026-08-25 08:40:27 +08:00
this . context . orderFulfillmentType = fulfillmentType
2026-08-24 19:58:35 +08:00
this . context . phase4ProducerMatrixMarker = matrixMarker
try {
2026-10-23 04:48:02 +08:00
if ( label === 'DROPSHIP-PRIMARY' && this . options . fixedCustomerId
&& this . options . modules . includes ( 'phase4-overseas-client-services' )) {
// 同一五箱订单还供应转运收货;造单前按既有异仓资源选线,避免出库后才发现无目标托盘。
const { findReusableTransferReceiptTarget } = await import ( new URL ( './admin-test-env-modules/warehouse-transfer-receipt.mjs' , import . meta . url ))
const target = await findReusableTransferReceiptTarget ({
sourceWarehouseId : String ( this . options . fixedPdaActors ? .[ 0 ] ? . warehouseId || '' ),
request : async ( method , path ) => {
const response = await this . request ( method , path )
const data = this . apiSuccess ( 'phase4-overseas-pda-fixture' , '转运造单前核验既有目标资源' , response )
if ( response . http !== 200 || String ( response . body ? . code ) !== '200' ) throw Error ( '转运目标资源查询失败' )
return data
},
check : ( name , ok ) => { if ( ! ok ) throw Error ( name ) },
})
if ( ! target ) throw Error ( '缺少转运收货既有异仓空闲托盘;未创建来源订单' )
this . createdResources . phase4TransferReceiptTargetPlan = target
this . context . waitSplitRoute = { ...( this . context . waitSplitRoute || {}),
includeDestinationCodes : [ target . pallet . fbaWarehouseCode ] }
}
if ( destinationType === 'FBA' && label === 'FBA-SECOND' ) {
const existingFba = ( this . createdResources . phase4InboundProducerFixtures ? . containers || [])
. filter ( item => item ? . destinationType === 'FBA'
&& item ? . fulfillmentType !== 'ONE_CLICK_DROPSHIP' )
const primaryCode = existingFba . map ( item => String ( item ? . destinationWarehouseCode || '' ). trim (). toUpperCase ()). find ( Boolean )
this . context . waitSplitRoute = {
...( this . context . waitSplitRoute || {}),
// The normal order route rotates the destination list from randomIndex;
// force this producer onto the next distinct priced FBA warehouse so
// dispatch can exercise a multi-destination candidate set.
randomIndex : 17 ,
destinationIndex : 1 ,
excludeDestinationCodes : primaryCode ? [ primaryCode ] : [],
}
}
2026-09-03 16:20:17 +08:00
// Cross-region variants only need a normal completed loading chain. The destructive A0
// inventory-snapshot mutation is covered once by an explicitly requested phase3-loading
// run; repeating it for every producer is unsafe for a populated shared warehouse and can
// become impossible when the bounded isolation guard refuses bulk location mutations.
restoreFixture = await this . preparePhase3LoadingFixture ( '' , false )
2026-09-09 00:25:34 +08:00
if ( fulfillmentType === 'ONE_CLICK_DROPSHIP' ) {
2026-09-09 01:09:31 +08:00
const inboundFees = await import ( new URL ( './admin-test-env-modules/inbound-fees.mjs' , import . meta . url ))
await inboundFees . ensureInitialCalculated ( this ,
this . createdResources . orchestrationFixtures ? . phase3Loading ,
{ scenario : `phase4- ${ label } -initial-fee` , deploymentBlocker : true })
2026-09-09 00:25:34 +08:00
}
2026-08-24 19:58:35 +08:00
const loading = await import ( new URL ( './admin-test-env-modules/phase3-loading.mjs' , import . meta . url ))
await this . executeModule ( `phase3-loading-phase4- ${ label } ` , () => loading . run ( this ))
const finalize = await import ( new URL ( './admin-test-env-modules/phase3-loading-finalize.mjs' , import . meta . url ))
await this . executeModule ( `phase3-loading-finalize-phase4- ${ label } ` , () => finalize . run ( this ))
2026-08-25 15:27:26 +08:00
const producerKey = this . createdResources . phase3LoadingCompletedProducer ? . resourceKey
2026-09-08 14:07:31 +08:00
const writeShippingEvidence = ( shippingReady , carrierScheduleQueryId = null ,
carrierLatestActualAt = null ) => {
2026-08-25 15:27:26 +08:00
const matrix = this . createdResources . phase4InboundProducerFixtures
const records = [ matrix ? . containers , matrix ? . byDestinationType ? . FBA ,
matrix ? . byDestinationType ? . OVERSEAS ]. filter ( Array . isArray )
for ( const values of records ) {
const target = values . find (( item ) => item ? . resourceKey === producerKey )
2026-09-08 14:07:31 +08:00
if ( target ) Object . assign ( target , {
shippingReady , carrierScheduleQueryId , carrierLatestActualAt ,
})
2026-08-25 15:27:26 +08:00
}
}
if ( producerKey ) writeShippingEvidence ( false )
2026-10-23 04:48:02 +08:00
if ( shippingRequired && ! producerKey ) {
2026-08-25 15:27:26 +08:00
this . assert ( 'phase4-overseas-pda-fixture' , ` ${ label } 产生者身份完整` , null , false ,
'phase3LoadingCompletedProducer.resourceKey=missing' ,
{ scenario : 'phase3-phase4-producer-fixture' , deploymentBlocker : true })
}
2026-10-23 04:48:02 +08:00
if ( shippingRequired && producerKey ) {
2026-08-25 15:27:26 +08:00
const customs = await import ( new URL ( './admin-test-env-modules/phase3-customs.mjs' , import . meta . url ))
await this . executeModule ( `phase3-customs-phase4- ${ label } ` , () => customs . run ( this ))
const serviceCenter = await import ( new URL ( './admin-test-env-modules/service-center-flow.mjs' , import . meta . url ))
await this . executeModule ( `service-center-flow-phase4- ${ label } ` , () => serviceCenter . run ( this ))
const portSchedules = await import ( new URL ( './admin-test-env-modules/phase3-port-schedules.mjs' , import . meta . url ))
await this . executeModule ( `phase3-port-schedules-phase4- ${ label } ` , () => portSchedules . run ( this ))
const outbound = await import ( new URL ( './admin-test-env-modules/phase3-outbound-dispatch.mjs' , import . meta . url ))
await this . executeModule ( `phase3-outbound-dispatch-phase4- ${ label } ` , () => outbound . run ( this ))
2026-09-08 23:26:09 +08:00
const documents = await import ( new URL ( './admin-test-env-modules/phase3-container-documents.mjs' , import . meta . url ))
await this . executeModule ( `phase3-container-documents-phase4- ${ label } ` , () => documents . run ( this ))
2026-08-25 15:27:26 +08:00
const carrierQueries = await import ( new URL ( './admin-test-env-modules/phase3-carrier-schedule-queries.mjs' , import . meta . url ))
await this . executeModule ( `phase3-carrier-schedule-queries-phase4- ${ label } ` , () => carrierQueries . run ( this ))
2026-10-23 04:48:02 +08:00
if ( ! pickupRequired ) {
// 第二柜先真实出仓并建立原生船期任务,保留不可接柜状态供PDA拒绝用例;Admin预约前再推进开船。
const source = this . createdResources . phase3CarrierScheduleQuery
const ready = source ? . ownedByModule === true && source . marker === variantMarker && Boolean ( source . queryId )
writeShippingEvidence ( false , ready ? String ( source . queryId ) : null )
this . assert ( 'phase4-overseas-pda-fixture' , ` ${ label } 已真实出仓但尚未开放接柜` , null , ready ,
`resourceKey= ${ producerKey } , queryPresent= ${ Boolean ( source ? . queryId ) } ` ,
{ scenario : 'phase3-phase4-producer-fixture' , deploymentBlocker : true })
} else {
2026-08-25 15:27:26 +08:00
const carrierNodes = await import ( new URL ( './admin-test-env-modules/phase4-carrier-nodes.mjs' , import . meta . url ))
await this . executeModule ( `phase4-carrier-pickup-ready-phase4- ${ label } ` ,
() => carrierNodes . runUntilPickupReady ( this ))
const pickup = this . createdResources . phase4CarrierPickupReady
const shippingReady = pickup ? . marker === variantMarker && pickup ? . ready === true
&& Boolean ( pickup ? . queryId )
2026-09-08 14:07:31 +08:00
writeShippingEvidence ( shippingReady , shippingReady ? String ( pickup . queryId ) : null ,
shippingReady ? pickup . latestActualAt : null )
2026-08-25 15:27:26 +08:00
this . assert ( 'phase4-overseas-pda-fixture' , ` ${ label } 通过真实出仓与船节点进入PICKUP_READY` , null ,
shippingReady ,
`resourceKey= ${ producerKey } , queryId= ${ pickup ? . queryId || 'missing' } , ready= ${ pickup ? . ready === true } ` ,
{ scenario : 'phase3-phase4-producer-fixture' , deploymentBlocker : true })
2026-10-23 04:48:02 +08:00
}
2026-08-25 15:27:26 +08:00
}
2026-08-24 19:58:35 +08:00
} finally {
if ( restoreFixture ) restoreFixture ()
this . marker = matrixMarker
for ( const [ key , snapshot ] of contextSnapshot ) {
if ( snapshot . present ) this . context [ key ] = snapshot . value
else delete this . context [ key ]
}
for ( const [ key , snapshot ] of createdSnapshot ) {
if ( snapshot . present ) this . createdResources [ key ] = snapshot . value
else delete this . createdResources [ key ]
}
}
}
async ensurePhase4PdaProducerMatrix () {
const readiness = () => this . createdResources . phase4InboundProducerFixtures ? . readiness || {}
2026-08-25 08:40:27 +08:00
if ( Number ( readiness (). dropshipContainerCount || 0 ) < 1 ) {
2026-08-25 15:27:26 +08:00
await this . runPhase4ProducerVariant ( 'DROPSHIP-PRIMARY' , 'FBA' , 'ONE_CLICK_DROPSHIP' , true )
2026-08-25 08:40:27 +08:00
}
2026-09-09 21:13:10 +08:00
const hasStandardFbaProducer = () => ( this . createdResources . phase4InboundProducerFixtures ? . containers || [])
. some (( item ) => item ? . destinationType === 'FBA'
&& item ? . fulfillmentType !== 'ONE_CLICK_DROPSHIP'
2026-09-10 21:18:52 +08:00
&& /-P4-FBA-STANDARD(?:-R\d+)?:/ . test ( String ( item ? . resourceKey || '' )))
2026-09-09 21:13:10 +08:00
if ( ! hasStandardFbaProducer ()) {
2026-09-09 20:36:53 +08:00
await this . runPhase4ProducerVariant ( 'FBA-STANDARD' , 'FBA' , 'STANDARD' , true )
2026-08-24 19:58:35 +08:00
}
2026-09-23 19:48:03 +08:00
const hasSecondStandardFbaProducer = () => ( this . createdResources . phase4InboundProducerFixtures ? . containers || [])
. some (( item ) => /- P4 - FBA - SECOND ( ?:- R \ d + ) ?: /.test(String(item?.resourceKey || '')))
if ( ! hasSecondStandardFbaProducer ()) {
await this . runPhase4ProducerVariant ( 'FBA-SECOND' , 'FBA' , 'STANDARD' , true )
}
2026-09-24 05:08:46 +08:00
const hasOperationsFbaProducer = () => ( this . createdResources . phase4InboundProducerFixtures ? . containers || [])
. some (( item ) => /- P4 - FBA - OPERATIONS ( ?:- R \ d + ) ?: /.test(String(item?.resourceKey || '')))
if ( ! hasOperationsFbaProducer ()) {
await this . runPhase4ProducerVariant ( 'FBA-OPERATIONS' , 'FBA' , 'STANDARD' , true )
}
2026-10-02 20:14:17 +08:00
const hasRouteFbaProducer = () => ( this . createdResources . phase4InboundProducerFixtures ? . containers || [])
. some (( item ) => /- P4 - FBA - ROUTE ( ?:- R \ d + ) ?: /.test(String(item?.resourceKey || '')))
if ( ! hasRouteFbaProducer ()) {
await this . runPhase4ProducerVariant ( 'FBA-ROUTE' , 'FBA' , 'STANDARD' , true )
}
2026-08-24 19:58:35 +08:00
if ( Number ( readiness (). overseasContainerCount || 0 ) < 1 ) {
2026-09-09 13:20:11 +08:00
await this . runPhase4ProducerVariant ( 'OVERSEAS-PRIMARY' , 'OVERSEAS' , 'ONE_CLICK_DROPSHIP' , true )
2026-08-24 19:58:35 +08:00
}
2026-09-10 10:25:16 +08:00
const hasSecondaryProducer = () => ( this . createdResources . phase4InboundProducerFixtures ? . containers || [])
2026-09-10 21:18:52 +08:00
. some (( item ) => /- P4 - SECONDARY ( ?:- R \ d + ) ?: /.test(String(item?.resourceKey || '')))
2026-09-10 10:25:16 +08:00
if ( ! hasSecondaryProducer ()) {
2026-08-25 15:27:26 +08:00
await this . runPhase4ProducerVariant ( 'SECONDARY' , 'FBA' , 'STANDARD' , false )
2026-08-24 19:58:35 +08:00
}
const matrix = this . createdResources . phase4InboundProducerFixtures
2026-08-25 15:27:26 +08:00
const containers = Array . isArray ( matrix ? . containers ) ? matrix . containers : []
const dropshipReady = containers . find (( item ) => item ? . fulfillmentType === 'ONE_CLICK_DROPSHIP' )
2026-09-09 20:36:53 +08:00
const standardFbaReady = containers . find (( item ) => item ? . destinationType === 'FBA'
&& item ? . fulfillmentType !== 'ONE_CLICK_DROPSHIP'
2026-09-10 21:18:52 +08:00
&& /-P4-FBA-STANDARD(?:-R\d+)?:/ . test ( String ( item ? . resourceKey || '' )))
2026-08-25 15:27:26 +08:00
const overseasReady = containers . find (( item ) => item ? . destinationType === 'OVERSEAS' )
2026-09-23 19:48:03 +08:00
const secondStandardFbaReady = containers . find (( item ) =>
/- P4 - FBA - SECOND ( ?:- R \ d + ) ?: /.test(String(item?.resourceKey || '')))
2026-09-24 05:08:46 +08:00
const operationsFbaReady = containers . find (( item ) =>
/- P4 - FBA - OPERATIONS ( ?:- R \ d + ) ?: /.test(String(item?.resourceKey || '')))
2026-10-02 20:14:17 +08:00
const routeFbaReady = containers . find (( item ) =>
/- P4 - FBA - ROUTE ( ?:- R \ d + ) ?: /.test(String(item?.resourceKey || '')))
2026-09-09 20:36:53 +08:00
const awaitingVariants = containers . filter (( item ) => [ 'SECONDARY' ]
2026-09-10 21:18:52 +08:00
. some (( label ) => new RegExp ( `-P4- ${ label } (?:-R\\d+)?:` ). test ( String ( item ? . resourceKey || '' ))))
2026-08-25 15:27:26 +08:00
const shippingMatrixReady = dropshipReady ? . shippingReady === true
&& Boolean ( dropshipReady ? . carrierScheduleQueryId )
2026-09-09 20:36:53 +08:00
&& standardFbaReady ? . shippingReady === true && Boolean ( standardFbaReady ? . carrierScheduleQueryId )
2026-09-23 19:48:03 +08:00
&& secondStandardFbaReady ? . shippingReady === true && Boolean ( secondStandardFbaReady ? . carrierScheduleQueryId )
2026-09-24 05:08:46 +08:00
&& operationsFbaReady ? . shippingReady === true && Boolean ( operationsFbaReady ? . carrierScheduleQueryId )
2026-10-02 20:14:17 +08:00
&& routeFbaReady ? . shippingReady === true && Boolean ( routeFbaReady ? . carrierScheduleQueryId )
2026-08-25 15:27:26 +08:00
&& overseasReady ? . shippingReady === true && Boolean ( overseasReady ? . carrierScheduleQueryId )
&& awaitingVariants . length >= 1
&& awaitingVariants . every (( item ) => item . shippingReady === false
2026-10-23 04:48:02 +08:00
&& Boolean ( item . carrierScheduleQueryId ))
2026-08-24 19:58:35 +08:00
this . assert ( 'phase4-overseas-pda-fixture' , '本轮三期跨区生产矩阵已就绪' , null ,
2026-08-25 15:27:26 +08:00
matrix ? . marker === this . marker && matrix ? . readiness ? . readyForPhase4Pda === true && shippingMatrixReady ,
`marker= ${ matrix ? . marker || 'missing' } , FBA= ${ matrix ? . readiness ? . fbaContainerCount ?? 0 } , OVERSEAS= ${ matrix ? . readiness ? . overseasContainerCount ?? 0 } , DROPSHIP= ${ matrix ? . readiness ? . dropshipContainerCount ?? 0 } , containers= ${ matrix ? . readiness ? . distinctContainerCount ?? 0 } , shippingReady= ${ containers . filter (( item ) => item ? . shippingReady === true ). length } , awaiting= ${ awaitingVariants . length } ` ,
2026-08-24 19:58:35 +08:00
{ scenario : 'phase3-phase4-producer-fixture' , deploymentBlocker : true })
}
2026-09-01 10:18:19 +08:00
async listAvailableLocationsForA0 ( warehouseId , module , options ) {
const locations = []
const seen = new Set ()
let expectedTotal = null
for ( let pageNumber = 1 ; pageNumber <= 100 ; pageNumber += 1 ) {
const response = await this . request ( 'GET' ,
`/api/platform/v1/locations?tenantId=0&page= ${ pageNumber } &size= ${ A0_LOCATION_PAGE_SIZE } &warehouseId= ${ encodeURIComponent ( warehouseId ) } &status=AVAILABLE` )
const page = this . apiSuccess ( module , `查询 A0 隔离前可用库位第 ${ pageNumber } 页` , response ,
`page= ${ pageNumber } ` , options )
if ( ! page ) return null
const pageTotal = Number ( page . total )
const records = Array . isArray ( page . records ) ? page . records : []
if ( ! Number . isInteger ( pageTotal ) || pageTotal < 0
|| ( expectedTotal !== null && pageTotal !== expectedTotal )) {
this . assert ( module , 'A0 隔离库位分页总数稳定' , response , false ,
`page= ${ pageNumber } , firstTotal= ${ expectedTotal ?? 'missing' } , currentTotal= ${ page . total ?? 'missing' } ` , options )
return null
}
expectedTotal ??= pageTotal
for ( const location of records ) {
const locationId = location ? . id == null ? '' : String ( location . id )
if ( locationId && ! seen . has ( locationId )) {
seen . add ( locationId )
locations . push ( location )
}
}
if ( locations . length >= expectedTotal ) break
if ( records . length === 0 ) break
}
const complete = locations . length === expectedTotal
this . assert ( module , 'A0 隔离库位分页完整且无重复' , null , complete ,
`total= ${ expectedTotal ?? 'missing' } , uniqueRecords= ${ locations . length } ` , options )
return complete ? locations : null
}
async disableAvailableLocationsForA0 ( warehouseId , requiredSlots = 1 ) {
2026-08-12 13:24:17 +08:00
const module = 'phase3-loading-fixture'
const options = { scenario : 'phase3-loading-a0-fixture' , deploymentBlocker : true }
if ( ! warehouseId ) {
this . assert ( module , 'A0 隔离收货仓 ID 完整' , null , false , 'warehouseId=missing' , options )
2026-09-01 10:18:19 +08:00
return { forced : false , disabled : [] }
}
const locations = await this . listAvailableLocationsForA0 ( warehouseId , module , options )
if ( ! locations ) return { forced : false , disabled : [] }
const isolation = planA0LocationIsolation ( locations , requiredSlots )
if ( ! isolation . enabled ) {
this . addStep ( module , 'A0 大仓保护:不批量停用普通库位' , null , true ,
`normal= ${ isolation . normalCount } , requiredSlots= ${ isolation . requiredSlots } , mutationsRequired= ${ isolation . mutationsRequired } , safetyLimit= ${ A0_MAX_LOCATION_MUTATIONS } ` ,
{ scenario : options . scenario })
return { forced : false , disabled : [], isolation }
2026-08-12 13:24:17 +08:00
}
const disabled = []
2026-08-13 20:00:30 +08:00
const skippedOccupied = []
2026-09-01 10:18:19 +08:00
for ( const location of isolation . targets ) {
2026-08-12 13:24:17 +08:00
const response = await this . request ( 'PATCH' ,
`/api/platform/v1/locations/ ${ encodeURIComponent ( location . id ) } /status?tenantId=0&warehouseId= ${ encodeURIComponent ( warehouseId ) } ` ,
{ status : 'DISABLED' , version : Number ( location . version ) })
const success = response ? . http === 200 && String ( response ? . body ? . code ) === '200'
2026-08-13 20:00:30 +08:00
const occupiedConflict = response ? . http === 200
&& String ( response ? . body ? . code ) === 'A0409'
&& String ( response ? . body ? . message ) === '预占或占用中的库位不能停用'
this . assert ( module , `临时停用 A0 隔离库位 ${ location . locationCode } ` , response , success || occupiedConflict ,
`locationId= ${ location . id } , beforeVersion= ${ location . version }${ occupiedConflict ? ', skipped=active-resource' : '' } ` , options )
2026-08-12 13:24:17 +08:00
if ( success ) disabled . push ({ id : String ( location . id ), code : location . locationCode , version : Number ( location . version ) + 1 })
2026-08-13 20:00:30 +08:00
if ( occupiedConflict ) skippedOccupied . push ({ id : String ( location . id ), code : location . locationCode })
2026-08-12 13:24:17 +08:00
}
const availableResponse = await this . request ( 'GET' ,
`/api/platform/v1/locations/available?tenantId=0&warehouseId= ${ encodeURIComponent ( warehouseId ) } &limit=200` )
2026-09-01 10:18:19 +08:00
const available = this . apiSuccess ( module , '停用后回查收货仓剩余普通库位' , availableResponse , '' , options )
const remainingNormal = Array . isArray ( available )
? available . filter (( item ) => item ? . sharedOverflow !== true ) : null
const forced = Array . isArray ( remainingNormal ) && remainingNormal . length < isolation . requiredSlots
this . assert ( module , '测量前 A0 溢出条件成立' , availableResponse , forced ,
`requiredSlots= ${ isolation . requiredSlots } , disabled= ${ disabled . length } , skippedOccupied= ${ skippedOccupied . map (( item ) => ` ${ item . code } : ${ item . id } ` ). join ( '|' ) || 'none' } , remainingNormal= ${ Array . isArray ( remainingNormal ) ? remainingNormal . length : 'missing' } ` , options )
return { forced , disabled , isolation }
2026-08-12 13:24:17 +08:00
}
2026-09-01 10:18:19 +08:00
async restoreAvailableLocationsAfterA0 ( warehouseId , result ) {
2026-08-12 13:24:17 +08:00
const module = 'phase3-loading-fixture'
const options = { scenario : 'phase3-loading-a0-fixture' , deploymentBlocker : true }
2026-09-01 10:18:19 +08:00
const disabledLocations = Array . isArray ( result ? . disabled ) ? result . disabled : []
2026-08-12 13:24:17 +08:00
for ( const location of disabledLocations ) {
const response = await this . request ( 'PATCH' ,
`/api/platform/v1/locations/ ${ encodeURIComponent ( location . id ) } /status?tenantId=0&warehouseId= ${ encodeURIComponent ( warehouseId ) } ` ,
{ status : 'AVAILABLE' , version : location . version })
this . assert ( module , `恢复 A0 隔离库位 ${ location . code } ` , response ,
response ? . http === 200 && String ( response ? . body ? . code ) === '200' ,
`locationId= ${ location . id } , restoreVersion= ${ location . version } ` , options )
}
this . createdResources . phase3LoadingA0Isolation = {
2026-09-01 10:18:19 +08:00
warehouseId , forced : result ? . forced === true ,
normalCount : result ? . isolation ? . normalCount ?? null ,
mutationsRequired : result ? . isolation ? . mutationsRequired ?? 0 ,
restored : disabledLocations . map (( item ) => item . id ), marker : this . marker ,
2026-08-12 13:24:17 +08:00
}
}
2026-08-08 10:11:04 +08:00
async run () {
2026-10-21 21:30:24 +08:00
if ( this . options . environment === 'test' && ! this . options . fixedCustomerId ) {
throw new Error ( 'TEST 验收必须配置 ADMIN_TEST_FIXED_CUSTOMER_ID;禁止自动创建测试客户' )
}
2026-08-26 11:10:42 +08:00
if ( this . options . merchantUsername && this . options . merchantPassword ) {
process . env . MERCHANT_TEST_USERNAME = this . options . merchantUsername
process . env . MERCHANT_TEST_PASSWORD = this . options . merchantPassword
}
2026-08-27 09:21:01 +08:00
if ( this . options . waitSplitCount > 1 || this . options . waitSplitPackageCount > 0
2026-09-02 17:57:35 +08:00
|| this . options . orderProfileMode === 'randomized' || this . options . routeMode === 'random'
|| this . options . originWarehouseName || this . options . channelName || this . options . channelCode ) {
2026-08-27 09:21:01 +08:00
this . applyWaitSplitVariant ( this . options . waitSplitSequence || 1 )
2026-09-02 16:48:05 +08:00
} else if ( this . options . originWarehouseName || this . options . channelName || this . options . channelCode ) {
this . context . waitSplitRoute = {
originWarehouseName : this . options . originWarehouseName || '' ,
channelName : this . options . channelName || '' ,
channelCode : this . options . channelCode || '' ,
randomize : false ,
randomIndex : 0 ,
destinationIndex : 0 ,
taxModeIndex : 0 ,
}
// A fixed non-default origin may not carry the Yiwu-only TP-001/YW-TP-002 fixtures.
// Reuse an existing pallet from the selected warehouse while keeping order data deterministic.
this . context . allowAnyExistingPallet = true
2026-08-27 09:21:01 +08:00
}
2026-09-01 21:55:27 +08:00
const loginResponse = await this . request ( 'POST' , '/api/auth/login' , { loginId : this . options . username , password : this . options . password }, '' )
2026-08-08 10:11:04 +08:00
const login = this . apiSuccess ( 'authentication' , '平台管理员登录' , loginResponse )
if ( ! login ? . accessToken ) this . fatal = '平台管理员登录失败,未执行业务模块'
else {
this . token = login . accessToken
2026-09-23 18:32:23 +08:00
if ( this . options . pdaUsername && this . options . pdaPassword ) {
const pdaLoginResponse = await this . request ( 'POST' , '/api/auth/login' , {
loginId : this . options . pdaUsername , password : this . options . pdaPassword ,
}, '' )
const pdaLogin = this . apiSuccess ( 'authentication' , '国内 PDA 作业员登录' , pdaLoginResponse )
this . pdaToken = pdaLogin ? . accessToken || ''
this . assert ( 'authentication' , 'PDA 作业员登录成功' , pdaLoginResponse , Boolean ( this . pdaToken ),
`tokenPresent= ${ Boolean ( this . pdaToken ) } ` , { deploymentBlocker : true , scenario : 'phase3-pda' })
}
2026-08-25 16:40:48 +08:00
this . refreshToken = login . refreshToken || ''
2026-09-29 19:48:34 +08:00
this . expiresAt = Number ( login . expiresIn ) > 0 ? Date . now () + Number ( login . expiresIn ) * 1000 : 0
2026-08-08 10:11:04 +08:00
const handlers = { customers : () => this . runCustomers () }
2026-08-08 13:32:20 +08:00
for ( let moduleIndex = 0 ; moduleIndex < this . options . modules . length ; moduleIndex += 1 ) {
2026-10-23 04:48:02 +08:00
if ( await this . stopAtBoundary ()) break
2026-08-08 13:32:20 +08:00
const module = this . options . modules [ moduleIndex ]
2026-09-29 19:48:34 +08:00
if ( this . expiresAt > 0 && Date . now () + 120_000 >= this . expiresAt ) {
await this . refreshSession ( `模块 ${ module } 执行前Token临近到期` )
2026-08-25 16:40:48 +08:00
}
2026-08-12 10:59:39 +08:00
const fullFinanceChain = [ 'finance-full-fixtures' , 'finance-reconciliation-full' , 'finance-settlement-full' ]. every (( name ) => this . options . modules . includes ( name ))
if ( module === 'finance' && fullFinanceChain ) {
this . modules . push ({ name : module , startedAt : new Date (). toISOString (), finishedAt : new Date (). toISOString (), success : true , passed : 0 , failed : 0 , fatal : null , skipped : 'finance-full-chain owns the finance source locks' })
continue
}
2026-08-08 13:32:20 +08:00
let restoreFixture = null
2026-09-09 21:13:10 +08:00
if ( module === 'phase4-tail-order-controls' || module === 'phase4-overseas-pda' ) {
2026-08-24 19:58:35 +08:00
try {
await this . ensurePhase4PdaProducerMatrix ()
} catch ( error ) {
const reason = error instanceof Error ? error . message : String ( error )
this . addStep ( 'phase4-overseas-pda-fixture' , '创建FBA/OVERSEAS/第二柜三期生产矩阵' , null ,
false , reason , { scenario : 'phase3-phase4-producer-fixture' , deploymentBlocker : true })
}
}
2026-08-08 13:32:20 +08:00
const ocrIndex = this . options . modules . indexOf ( 'inbound-weight-ocr' )
if ( module === 'inbound-measurement' && ocrIndex >= 0 && ocrIndex < moduleIndex ) {
try {
restoreFixture = await this . prepareInboundMeasurementFixture ()
} catch ( error ) {
const reason = error instanceof Error ? error . message : String ( error )
this . addStep ( 'orders-inbound-measurement-fixture' , '创建独立测量入库单' , null , false , reason ,
{ scenario : 'inbound-resource-isolation' })
}
}
2026-08-08 21:34:39 +08:00
if ( module === 'phase3-container-loading' ) {
try {
restoreFixture = await this . preparePhase3ContainerFixture ()
} catch ( error ) {
const reason = error instanceof Error ? error . message : String ( error )
this . addStep ( 'phase3-container-fixture' , '创建独立柜次级入库单' , null , false , reason ,
{ scenario : 'phase3-container-resource-isolation' })
}
}
2026-08-12 10:59:39 +08:00
if ( module === 'phase3-loading' ) {
try {
2026-09-02 19:29:23 +08:00
// The A0 stale-inventory mutation is intentionally destructive and expensive on
// a large shared test warehouse. Run it only when phase3-loading was explicitly
// requested; downstream phase4/finance chains need the normal loading fixture only.
const requireSharedOverflow = this . options . requestedModules . includes ( 'phase3-loading' )
restoreFixture = await this . preparePhase3LoadingFixture ( '' , requireSharedOverflow )
2026-08-12 10:59:39 +08:00
} catch ( error ) {
this . marker = this . marker . replace ( /-LOAD$/ , '' )
const reason = error instanceof Error ? error . message : String ( error )
this . addStep ( 'phase3-loading-fixture' , '创建独立 WAIT_SPLIT 装柜入库单' , null , false , reason ,
{ scenario : 'phase3-loading-resource-isolation' })
}
}
2026-08-28 22:39:18 +08:00
if ( module === 'phase3-container-customs' ) {
try {
const fixture = await import ( new URL ( './admin-test-env-modules/phase3-container-customs-fixture.mjs' , import . meta . url ))
restoreFixture = await fixture . prepare ( this )
} catch ( error ) {
const reason = error instanceof Error ? error . message : String ( error )
this . addStep ( 'phase3-container-customs-fixture' , '创建独立BUY+TAX混合整柜' , null , false ,
reason , { scenario : 'phase3-container-customs-mixed-fixture' , deploymentBlocker : true })
}
}
2026-10-23 04:48:02 +08:00
if ( await this . stopAtBoundary ()) {
if ( restoreFixture ) restoreFixture ()
break
}
2026-08-08 10:11:04 +08:00
let handler = handlers [ module ]
if ( ! handler ) {
try {
const extension = await import ( new URL ( `./admin-test-env-modules/ ${ module } .mjs` , import . meta . url ))
if ( typeof extension . run !== 'function' ) throw new Error ( '模块必须导出 async function run(context)' )
handler = () => extension . run ( this )
} catch ( error ) {
const reason = error instanceof Error ? error . message : String ( error )
this . addStep ( module , '模块注册检查' , null , false , `无法加载模块: ${ reason } ` )
this . modules . push ({ name : module , success : false , passed : 0 , failed : 1 , fatal : reason })
2026-08-08 13:32:20 +08:00
if ( restoreFixture ) restoreFixture ()
2026-08-08 10:11:04 +08:00
continue
}
}
2026-08-08 13:32:20 +08:00
try {
await this . executeModule ( module , handler )
} finally {
if ( restoreFixture ) restoreFixture ()
}
2026-10-23 04:48:02 +08:00
if ( await this . stopAtBoundary ()) break
2026-10-21 21:30:24 +08:00
if ( module === 'merchant-qualification-fixture' && this . options . fixedCustomerId
&& ! this . context . merchantQualificationFixture ? . reused ) {
this . fatal = '固定客户身份校验失败,停止后续造数'
break
}
2026-08-27 09:21:01 +08:00
if ( module === 'inbound-measurement'
2026-10-23 02:13:47 +08:00
&& ! this . options . prepareMeasurementReview
2026-08-27 09:21:01 +08:00
&& this . captureWaitSplitFixture ( this . options . waitSplitSequence || 1 )) {
2026-08-26 11:10:42 +08:00
await this . createAdditionalWaitSplitFixtures ()
}
2026-08-25 16:40:48 +08:00
const laterOverseasModule = this . options . modules . slice ( moduleIndex + 1 )
. some (( name ) => name . startsWith ( 'phase4-overseas-' ))
if ( module === 'system' && laterOverseasModule ) {
const refreshed = await this . refreshSession ( 'system模块权限与角色变更完成' )
if ( refreshed ) {
2026-09-29 19:48:34 +08:00
const probe = await this . request ( 'GET' , '/api/platform/v1/last-leg/orders?page=1&size=1' )
this . assert ( 'authentication' , 'system后新admin统一Token只读探针可用' , probe ,
2026-08-25 16:40:48 +08:00
probe . http === 200 && String ( probe . body ? . code ) === '200' ,
`HTTP= ${ probe . http } , code= ${ probe . body ? . code ?? 'missing' } , requestId= ${ probe . requestId || 'missing' } , traceId= ${ probe . traceId || 'missing' } ` ,
2026-09-29 19:48:34 +08:00
{ scenario : 'session-refresh' , deploymentBlocker : true })
2026-08-25 16:40:48 +08:00
}
}
2026-08-08 10:11:04 +08:00
}
}
2026-10-02 20:14:17 +08:00
if ( this . options . verifyUnloadEventRecovery ) {
this . addStep ( 'phase4-overseas-pda' , '显式卸柜故障场景必须实际完成且只验证本轮一个柜' , null ,
unloadRecoveryWasVerified ( this ), '不以未触发故障或普通收货成功代替事件恢复证据' ,
{ scenario : 'reverse-unload-event-recovery' , deploymentBlocker : true })
}
2026-08-10 12:23:43 +08:00
const reportSurface = JSON . stringify ({
steps : this . steps , modules : this . modules , scenarios : this . scenarios ,
deploymentBlockers : this . deploymentBlockers , coverageGaps : this . coverageGaps ,
createdResources : this . createdResources ,
})
2026-10-23 04:48:02 +08:00
const forbiddenValues = [ this . options . password , this . options . merchantPassword , this . options . fixedSecondaryCustomer ? . password ,
2026-09-29 19:48:34 +08:00
this . token , this . refreshToken ]
2026-08-25 16:40:48 +08:00
. filter (( value ) => typeof value === 'string' && value . length >= 8 )
2026-08-10 12:23:43 +08:00
const sensitivePatterns = [
/"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i ,
/\bBearer\s+[A-Za-z0-9._~-]{16,}/i ,
/\beyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/ ,
/data:(?:image|application)\/[A-Za-z0-9.+-]+;base64,/i ,
/"(?:bytes|base64|ocrText|ocrRawText)"\s*:/i ,
]
const sensitive = forbiddenValues . some (( value ) => reportSurface . includes ( value ))
|| sensitivePatterns . some (( pattern ) => pattern . test ( reportSurface ))
this . addStep ( 'report-security' , '报告敏感信息扫描' , null , ! sensitive ,
sensitive ? '检测到密码、Token、二进制/base64 或 OCR 原文字段' : '未发现密码、Token、文件二进制/base64 或 OCR 原文字段' )
2026-08-25 09:50:02 +08:00
const summary = buildAcceptanceSummary ( this . steps , this . modules , this . coverageGaps )
2026-08-08 10:11:04 +08:00
return {
2026-10-09 17:21:56 +08:00
reportName : this . options . prepareDomesticPdaH5 ? '国内 PDA H5 待拣任务准备(非全链路验收)' : this . options . environment === 'gray' ? '灰度环境 FBA 定向闭环 HTTP API 验收' : '后台管理测试环境 HTTP API 验收' , marker : this . marker ,
2026-10-08 17:07:57 +08:00
environment : this . options . environment || 'test' , gateway : this . options . gateway ,
2026-10-21 13:22:35 +08:00
executionScope : {
requestedModules : [...( this . options . requestedModules || [])],
plannedModules : [... this . options . modules ],
defaultModules : [... DEFAULT_MODULES ],
defaultPlanSelected : DEFAULT_MODULES . every (( name ) => this . options . modules . includes ( name )),
2026-10-23 04:48:02 +08:00
stoppedByRequest : this . stopRequested === true ,
2026-10-23 02:13:47 +08:00
preparationOnly : this . options . prepareDomesticPdaH5 === true || this . options . prepareDropshipManualCount === true || this . options . prepareMeasurementReview === true ,
2026-10-21 13:22:35 +08:00
returnMainChainOnly : this . options . returnMainChainOnly === true ,
evidenceType : 'HTTP' ,
pageAcceptance : 'NOT_VERIFIED' ,
},
2026-08-21 10:38:28 +08:00
generatedAt : new Date (). toISOString (), startedAt : this . startedAt ,
2026-08-25 09:50:02 +08:00
success : this . fatal === null && summary . failed === 0 , fatal : this . fatal ,
2026-10-21 21:30:24 +08:00
fullCoverage : this . coverageGaps . length === 0 && ! this . modules . some ( module => module . skipped ),
2026-10-09 17:21:56 +08:00
...( this . options . prepareDomesticPdaH5 ? { fullAcceptance : false , purpose : 'DOMESTIC_PDA_H5_FIXTURE' } : {}),
2026-10-23 02:13:47 +08:00
...( this . options . prepareMeasurementReview ? { fullAcceptance : false , purpose : 'MEASUREMENT_REVIEW_UI_FIXTURE' } : {}),
2026-08-25 09:50:02 +08:00
summary ,
2026-08-08 10:11:04 +08:00
modules : this . modules , scenarios : this . scenarios , deploymentBlockers : this . deploymentBlockers ,
2026-08-08 15:25:46 +08:00
coverageGaps : this . coverageGaps ,
2026-08-08 10:11:04 +08:00
createdResources : this . createdResources ,
excluded : [
{ scope : 'hidden-prototypes' , reason : '隐藏原型页面尚无已确认后端契约' },
2026-10-08 17:07:57 +08:00
...( this . options . environment === 'gray' ? [{ scope : 'full-regression-and-fault-injection' ,
reason : '仅执行显式 FBA 闭环及所需正式业务依赖;不覆盖默认全量、TEST 故障注入、真实邮件或供应商自动查询' }] : []),
2026-08-08 10:11:04 +08:00
],
securityNote : '报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。' ,
steps : this . steps ,
}
}
}
2026-10-02 20:14:17 +08:00
export function unloadRecoveryWasVerified ( run ) {
const records = Object . entries ( run . createdResources ? . phase4OverseasPda ? . receivingCompletionEvidence || {})
. filter (([, row ]) => row . eventRecovery )
if ( records . length !== 1 ) return false
const [ bookingId , row ] = records [ 0 ]
return row . status === 'VERIFIED' && row . eventRecoveryInventoryUnchanged === true
&& row . eventRecovery . restored === true && row . eventRecovery . marker === run . marker
&& row . eventRecovery . containerBookingId === bookingId
}
export async function configureReadOnlyEvidence ( run , environment , createReaders , faultFactories ) {
2026-10-08 17:07:57 +08:00
assertGrayExecutionScope ( run . options )
2026-10-02 20:14:17 +08:00
if ( ! run . options . modules . some ( name => [ 'phase4-overseas-pda' , 'phase5-root-fulfillment' , 'manual-state-matrix' ]. includes ( name ))) return
2026-10-08 17:07:57 +08:00
const factory = createReaders || ( run . options . environment === 'gray'
? ( await import ( './gray-regression-evidence.mjs' )). createGrayEvidenceReaders
: ( await import ( './fba-core-regression-evidence.mjs' )). createTestEvidenceReaders )
2026-10-02 20:14:17 +08:00
const readers = factory ( environment )
await readers . preflight ()
run . context . phase4ReceivingEvidenceReader = readers . readReceiving
run . context . f40ReadOnlyOutboxEvidence = readers . readCompletion
2026-10-08 17:07:57 +08:00
if ( run . options . environment === 'gray' ) return
2026-10-02 20:14:17 +08:00
const faults = []
if ( run . options . modules . includes ( 'phase4-cross-region-events' )) {
const { createTestFaultExecutor } = faultFactories || await import ( './test-event-fault-executor.mjs' )
const fault = createTestFaultExecutor ( environment )
run . context . beforeLoadingComplete = fault . prepare
run . context . directDeliveryReplayEvidenceReader = fault . readProof
faults . push ( fault )
}
if ( run . options . verifyUnloadEventRecovery ) {
const { createTestUnloadFaultExecutor } = faultFactories || await import ( './test-event-fault-executor.mjs' )
const fault = createTestUnloadFaultExecutor ( environment )
faults . push ( fault )
let selected = null
run . context . beforeReceivingComplete = async scope => {
if ( selected ) return
selected = { marker : run . marker , customerId : scope . customerId , containerBookingId : scope . bookingId }
await fault . prepare ( selected )
}
run . context . afterReceivingComplete = async scope => {
if ( ! selected || scope . bookingId !== selected . containerBookingId || scope . customerId !== selected . customerId ) return null
return fault . readProof ( selected )
}
}
if ( faults . length ) return {
cleanup : async () => {
const errors = []
for ( const fault of [... faults ]. reverse ()) {
try { await fault . cleanup () } catch { errors . push ( 'TEST事件故障验收恢复失败,保留原任务恢复记录' ) }
}
if ( errors . length ) throw new Error ( errors . join ( '; ' ))
},
}
}
2026-08-08 10:11:04 +08:00
async function main () {
2026-08-24 19:58:35 +08:00
const options = validateExecutionOptions ( parseArguments ( process . argv . slice ( 2 )))
2026-08-08 10:11:04 +08:00
const timestamp = new Date (). toISOString (). replace ( /\D/g , '' ). slice ( 0 , 14 )
const outputPath = resolve ( options . output || `reports/admin-test-env-acceptance- ${ timestamp } .json` )
2026-10-02 20:14:17 +08:00
const run = new AcceptanceRun ( options )
const fault = await configureReadOnlyEvidence ( run , process . env )
let report
try { report = await run . run () }
finally {
try { await fault ? . cleanup () }
catch ( error ) { run . addStep ( 'phase4-cross-region-events' , '故障验收后必须恢复原服务配置与健康状态' , null ,
false , String ( error . message ), { scenario : 'direct-delivery-replay' , deploymentBlocker : true }) }
}
report . summary = buildAcceptanceSummary ( run . steps , run . modules , run . coverageGaps )
report . success = report . fatal === null && report . summary . failed === 0
2026-08-08 10:11:04 +08:00
await mkdir ( dirname ( outputPath ), { recursive : true })
await writeFile ( outputPath , ` ${ JSON . stringify ( report , null , 2 ) } \n` , 'utf8' )
console . log ( `REPORT= ${ outputPath } ` )
2026-08-25 09:50:02 +08:00
console . log ( `RESULT= ${ report . success ? 'PASS' : 'FAIL' } total= ${ report . summary . total } passed= ${ report . summary . passed } failed= ${ report . summary . failed } metric=assertions moduleRuns= ${ report . summary . moduleRuns } uniqueModules= ${ report . summary . uniqueModules } uniqueRequests= ${ report . summary . uniqueRequests } skippedModules= ${ report . summary . skippedModules } ` )
2026-08-08 10:11:04 +08:00
process . exitCode = report . success ? 0 : 1
}
2026-08-12 10:59:39 +08:00
if ( process . argv [ 1 ] && import . meta . url === pathToFileURL ( resolve ( process . argv [ 1 ])). href ) {
main (). catch (( error ) => { console . error ( error instanceof Error ? error . message : String ( error )); process . exitCode = 1 })
}