import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, existsSync } from 'node:fs' import { dirname, resolve, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { isDeepStrictEqual } from 'node:util' import { createHash } from 'node:crypto' import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' import { redactEvidence } from './fba-core-regression-test-env.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const FEES = '/api/platform/v1/last-leg/finance-fee-sources' const TYPE = 'OVERSEAS_WAREHOUSE_FEE' const id = value => String(value ?? '') const check = (value, message) => { if (!value) throw Error(message) } export function parseFeeVoidArguments(args) { const config = { execute: false, environment: 'TEST', runId: 'plan' }, seen = new Set() for (let i = 0; i < args.length; i++) { const arg = args[i] if (['--plan', '--execute'].includes(arg)) { check(!seen.has('action'), 'Choose one execution action'); seen.add('action'); config.execute = arg === '--execute'; continue } const field = { '--environment': 'environment', '--run-id': 'runId', '--customer-id': 'customerId', '--control-customer-id': 'controlCustomerId', '--warehouse-id': 'warehouseId' }[arg] check(field && !seen.has(arg) && args[i + 1] && !args[i + 1].startsWith('--'), 'Invalid or duplicate argument') seen.add(arg); config[field] = args[++i] } validateConfig(config) if (config.execute) check(seen.has('--environment'), 'Execution requires explicit TEST') return config } function validateConfig(config) { check(config.environment === 'TEST' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId), 'TEST and safe run-id required') if (config.execute) check(config.runId !== 'plan' && ['customerId', 'controlCustomerId', 'warehouseId'].every(key => typeof config[key] === 'string' && /^[1-9][0-9]{0,19}$/.test(config[key])) && config.customerId !== config.controlCustomerId, 'Two explicit distinct customers and warehouse required') } export function feeVoidPlan(config) { return { environment: 'TEST', execute: config.execute, runId: config.runId, customerId: config.customerId, controlCustomerId: config.controlCustomerId, warehouseId: config.warehouseId, output: `reports/fee-void-${config.runId}`, sourceType: TYPE, amount: 17.31, currency: 'CNY', stages: ['read customer/warehouse and isolation baselines', 'create/submit/approve new warehouse service fee', 'wait original publication', 'inject only VOIDED ACK loss', 'original DEAD retry and full runtime restore', 'same-command replay and isolation readback'], limits: 'Only a new unbilled TEST receivable. Retain fixtures and failures, never automatically restart. No business SQL writes. Exclusive Lastleg fault window; no overlapping deployments or writers.' } } export async function runFeeVoid(config, { env = process.env, outputRoot = ROOT, readers, faultFactory, contextFactory = options => new AcceptanceRun(options), wait = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) { validateConfig(config) if (!config.execute) return feeVoidPlan(config) check(env.CHONGCHONG_ENV === 'test' && env.CHONGCHONG_ADMIN_USERNAME && env.CHONGCHONG_ADMIN_PASSWORD, 'Explicit TEST credentials required') const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) check(['http:', 'https:'].includes(base.protocol) && ['/api', '/api/'].includes(base.pathname) && !base.username && !base.password && !base.search && !base.hash, 'Invalid TEST gateway') readers ||= createTestEvidenceReaders(env) faultFactory ||= (await import('./test-event-fault-executor.mjs')).createTestFeeVoidFaultExecutor check(typeof faultFactory === 'function' && typeof readers.readFleetInventory === 'function', 'Fee fault executor and complete inventory reader required') const reports = resolve(outputRoot, 'reports'), output = join(reports, `fee-void-${config.runId}`) mkdirSync(reports, { recursive: true }) for (const name of ['.fba-core-regression.lock', '.fleet-acceptance.lock']) check(!existsSync(join(reports, name)), 'Another shared-environment acceptance is running or needs review') const lockPath = join(reports, '.test-event-recovery.lock'), lock = openSync(lockPath, 'wx') const report = { success: false, stage: 'PREFLIGHT', requests: [], inventory: [], goalComplete: false } let context, fault, initialized = false, refreshToken, attempted = false, prepared = false const secrets = [env.CHONGCHONG_ADMIN_PASSWORD, env.JENKINS_API_TOKEN] const save = () => { if (initialized) writeFileSync(join(output, 'report.json'), JSON.stringify(redactEvidence(report, secrets), null, 2)) } try { writeFileSync(lock, JSON.stringify({ pid: process.pid, runId: config.runId })) mkdirSync(output); initialized = true writeFileSync(join(output, 'manifest.json'), JSON.stringify(feeVoidPlan(config), null, 2)) await readers.preflight() context = contextFactory(validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) })) report.marker = context.marker const request = async (method, path, body, headers = {}) => { report.pending = { method, path, body }; save() const result = await context.request(method, path, body, context.token, headers) report.requests.push({ method, path, http: result.http, code: result.body?.code, requestId: result.requestId, traceId: result.traceId }) delete report.pending; save() check(result.http === 200 && id(result.body?.code) === '200', `Fee recovery request failed: ${method} ${path} (${result.http}/${result.body?.code})`) return result.body.data } const login = await request('POST', '/api/auth/login', { loginId: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD }) check(login?.accessToken && login.refreshToken, 'Complete administrator session required') context.token = login.accessToken; refreshToken = login.refreshToken; secrets.push(context.token, refreshToken) const customers = [] for (const customerId of [config.customerId, config.controlCustomerId]) { const customer = await request('GET', `/api/platform/v1/iam/tenants/${customerId}`) check(id(customer.id) === customerId && customer.status === 'ACTIVE' && customer.tenantName, 'Exact active customer required') customers.push(customer) } const warehouse = await request('GET', `/api/platform/v1/warehouse-admin/${config.warehouseId}?tenantId=0`) check(id(warehouse.warehouse?.id) === config.warehouseId && id(warehouse.warehouse.tenantId) === '0' && warehouse.warehouse.status === 'ACTIVE' && warehouse.warehouse.warehouseType === 'OVERSEAS', 'Exact active overseas warehouse required') const feeList = customerId => request('GET', `${FEES}?${new URLSearchParams({ customerId })}`) const pool = customerId => request('GET', `/api/platform/finance/v1/fee-pool?${new URLSearchParams({ customerId, direction: 'RECEIVABLE' })}`) const snapshot = async () => { const value = [] for (const customerId of [config.customerId, config.controlCustomerId]) { const sources = await feeList(customerId), finance = await pool(customerId) check(Array.isArray(sources) && sources.every(row => id(row.customerId) === customerId) && Array.isArray(finance) && finance.every(row => id(row.customerId) === customerId), 'Customer fee scope mismatch') const inventory = await readers.readFleetInventory({ marker: context.marker, customerId, warehouseId: config.warehouseId }) value.push({ customerId, sources, finance, inventory }) } return value } report.before = await snapshot() report.stage = 'CREATE_NEW_FEE' const body = { customerId: config.customerId, customerName: customers[0].tenantName, sourceType: TYPE, businessType: 'WAREHOUSE_SERVICE', businessId: null, businessNo: `${context.marker}-VOID-TEST`, warehouseId: config.warehouseId, feeItemCode: 'TEST-VOID-ACK', feeItemName: '受控测试作废响应丢失费用', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), currency: 'CNY', amount: 17.31, paymentVoucherFileIds: [] } report.createBody = body let fee = await request('POST', FEES, body, { 'Idempotency-Key': `${context.marker}-CREATE` }) report.sourceFeeId = id(fee.sourceFeeId) check(/^[1-9][0-9]+$/.test(report.sourceFeeId) && fee.reviewStatus === 'DRAFT' && id(fee.customerId) === config.customerId && Number(fee.sourceVersion) === 1, 'New isolated fee draft required') const path = `${FEES}/${report.sourceFeeId}` fee = await request('POST', `${path}/submit`, { idempotencyKey: `${context.marker}-SUBMIT` }) check(fee.reviewStatus === 'PENDING', 'New fee must await review') fee = await request('POST', `${path}/review`, { decision: 'APPROVE', reason: context.marker, idempotencyKey: `${context.marker}-REVIEW` }) check(fee.reviewStatus === 'APPROVED' && fee.publicationStatus === 'PUBLISHED' && fee.sourceStatus === 'ACTIVE', 'Approved active source required') report.approved = fee let published = [] for (let i = 0; i < 30; i++) { published = (await pool(config.customerId)).filter(row => id(row.sourceId) === report.sourceFeeId && Number(row.sourceVersion) === 1) if (published.length) break await wait(1000) } check(published.length === 1 && Number(published[0].amount) === 17.31 && published[0].currency === 'CNY', 'Original approved fee must exist exactly once in Finance') report.originalFinance = published report.scope = { marker: context.marker, customerId: config.customerId, sourceFeeId: report.sourceFeeId, sourceVersion: '1', sourceType: TYPE } fault = faultFactory(env) report.stage = 'PREPARE_INTENT'; attempted = true; save() await fault.prepare(report.scope) prepared = true report.stage = 'VOID_REQUEST' report.voidRequest = { expectedVersion: 1, reason: '受控TEST作废确认响应丢失验收', idempotencyKey: `${context.marker}-VOID` } const pending = await request('POST', `${path}/void`, report.voidRequest) check(pending.sourceStatus === 'ACTIVE' && pending.voidStatus === 'PENDING_FINANCE', 'Sending void request must not confirm business-side cancellation') report.pendingSource = pending; report.stage = 'WAIT_DEAD_AND_RECOVER'; save() report.eventProof = await fault.readProof(report.scope) report.stage = 'RESTORED_READBACK'; save() const confirmed = (await feeList(config.customerId)).filter(row => id(row.sourceFeeId) === report.sourceFeeId) check(confirmed.length === 1 && confirmed[0].sourceStatus === 'VOIDED' && confirmed[0].voidStatus === 'CONFIRMED' && Number(confirmed[0].sourceVersion) === 1, 'Matching Finance ACK must confirm only the original source version') report.confirmedSource = confirmed[0] const replay = await request('POST', `${path}/void`, report.voidRequest) check(isDeepStrictEqual(replay, confirmed[0]), 'Same void command replay changed the confirmed source') report.after = await snapshot() check(isDeepStrictEqual(report.before[1], report.after[1]), 'Other customer fees or inventory changed') check(isDeepStrictEqual(report.before[0].inventory, report.after[0].inventory), 'Fee cancellation changed inventory') check(isDeepStrictEqual(report.before[0].sources, report.after[0].sources.filter(row => id(row.sourceFeeId) !== report.sourceFeeId)), 'Unrelated original source changed') check(isDeepStrictEqual(report.before[0].finance, report.after[0].finance), 'Voided fee remains in pool or unrelated finance row changed') report.success = true; report.stage = 'VERIFIED' } catch (error) { report.error = error.message; report.success = false; report.stage = 'FAILED' } finally { if (fault) try { report.restoration = await fault.cleanup() const noMutation = !prepared && report.restoration?.attempted === false if (attempted && !noMutation && !(report.restoration?.restored && report.restoration?.healthy && report.restoration?.proxyStopped)) throw Error('Fee void restoration not verified') } catch (error) { report.success = false; report.stage = 'RESTORATION_FAILED'; report.restorationError = error.message } if (context?.token) try { const result = await context.request('POST', '/api/auth/logout', { refreshToken }, context.token) report.sessionClosed = result.http === 200 && id(result.body?.code) === '200' if (!report.sessionClosed) report.success = false } catch (error) { report.success = false; report.sessionClosed = false; report.logoutError = error.message } try { save() } finally { closeSync(lock); unlinkSync(lockPath) } } return report } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { try { const config = parseFeeVoidArguments(process.argv.slice(2)), result = await runFeeVoid(config) console.log(JSON.stringify(config.execute ? { success: result.success, stage: result.stage, error: result.error, sourceFeeId: result.sourceFeeId, restored: result.restoration?.restored, output: feeVoidPlan(config).output } : result, null, 2)) if (config.execute && !result.success) process.exitCode = 1 } catch (error) { console.error(error.message); process.exitCode = 1 } }