This commit is contained in:
2026-10-08 17:07:57 +08:00
parent 16d2d9111a
commit c1ddb658bd
111 changed files with 2914 additions and 941 deletions
@@ -4,11 +4,12 @@ import { resolve, dirname, join, isAbsolute, extname } from 'node:path'
import { homedir } from 'node:os'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createHash, randomUUID } from 'node:crypto'
import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary, createFleetPoolGrayOptions } from './admin-test-env-acceptance.mjs'
import { parseCrossCustomerArguments } from './fleet-cross-customer-test-env.mjs'
import { createFleetCrossCustomerFixture } from './admin-test-env-modules/fleet-cross-customer-fixture.mjs'
import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs'
import { createGrayEvidenceReaders } from './gray-regression-evidence.mjs'
import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs'
import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs'
@@ -41,6 +42,13 @@ export function parseUiFixtureArguments(args) {
}
export function uiFixturePlan(config) {
if (config.environment === 'GRAY') return { ...uiFixturePlan({ ...config, environment: 'TEST' }), environment: 'GRAY',
output: `reports/fleet-capacity-pool-ui-gray-${config.runId}`,
purpose: 'GRAY UI fixture only: one OPEN 30/18-space trip, four complete non-reserving drafts, two customer logins, sales A with quote:list, sales B without quote permission, manage dispatcher and one warehouse-bound PDA for H5.',
permissions: { salesA: uiActorPermissions('SALES_A', true), salesB: uiActorPermissions('SALES_B', true), manager: uiActorPermissions('MANAGER', true) },
stopsAt: 'OPEN_AND_4_DRAFTS_READY_FOR_UI', retainedPda: true,
schedule: 'From preparation: cutoff +8h, departure +9h, delivery ETA +10h, driver expected end +12h; no actual departure.',
boundary: 'No automatic rerun/resume, SQL mutations, deployment, pool confirmation, PDA picking, departure or receipt signature. Retain business samples and all six UI logins including the warehouse-bound PDA; logout script sessions. Actual profile remains prod/gray; production-identity read-only evidence required.' }
return { execute: Boolean(config.execute), environment: 'TEST', runId: config.runId,
output: `reports/fleet-capacity-pool-ui-${config.runId}`, privateCredentialsFile: config.privateCredentialsFile,
purpose: 'UI fixture only: one OPEN 30-space trip with valid 18-space owned plan and 24 non-reserving drafts, two minimal sales logins, one manage login, two qualified customer logins.',
@@ -50,6 +58,29 @@ export function uiFixturePlan(config) {
boundary: 'No automatic rerun/resume, SQL mutations, deployment, pool confirmation, PDA picking, departure, receipt signature or completed-order fabrication. Retain business samples and UI accounts; disable the fixture-only PDA and logout script sessions.' }
}
export function uiActorPermissions(kind, gray = false) {
demand(['SALES_A', 'SALES_B', 'MANAGER'].includes(kind), 'UI_ACTOR_KIND_REQUIRED')
if (kind === 'MANAGER') return [PREFIX + 'manage']
return [...['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(name => PREFIX + name),
...(gray && kind === 'SALES_A' ? ['platform:fleet:quote:list'] : [])]
}
export function grayUiSchedule(now = Date.now()) {
demand(Number.isFinite(now), 'VALID_GRAY_UI_CLOCK_REQUIRED')
// Persisted business DATETIME columns have second precision.
const base = Math.floor(now / 1000) * 1000
const instant = hours => new Date(base + hours * 3600000).toISOString()
const delivery = new Date(base + 10 * 3600000)
return { cutoffAt: instant(8), plannedDepartureAt: instant(9), expectedDriverEndAt: instant(12),
plannedDeliveryAt: new Date(delivery.getTime() - delivery.getTimezoneOffset() * 60000).toISOString().slice(0, 19) }
}
export function applyGrayUiTransportWindow(method, path, body, schedule) {
if (method !== 'POST' || !/^\/api\/platform\/v1\/last-leg\/dispatches\/[^/]+\/transfer-delivery$/.test(path)) return body
demand(body && schedule?.plannedDeliveryAt, 'GRAY_UI_DELIVERY_WINDOW_REQUIRED')
return { ...body, plannedDeliveryAt: schedule.plannedDeliveryAt }
}
/** Private directory override is for isolated offline tests, never exposed as a CLI option. */
export function createPrivateCredentialStore(file, initial, { privateDirectory = PRIVATE_DIRECTORY } = {}) {
const target = privateCredentialsPath(file, privateDirectory)
@@ -75,36 +106,50 @@ export function publicUiEvidence(value, secrets = []) {
return stripped(redactFleetWrite(value, secrets))
}
export function uiInquiryBody(offer, customerId, index, key) {
export function uiInquiryBody(offer, customerId, index, key, complete = false) {
const destination = offer.destinations?.find(row => row.destinationKey?.startsWith('FBA:'))
demand(offer.status === 'OPEN' && offer.canCreateInquiry && destination, 'OPEN_FBA_OFFER_REQUIRED')
return { tripRecordVersion: offer.tripRecordVersion, offerRecordVersion: offer.recordVersion,
customerId, businessType: index % 2 === 0 ? 'LOCAL_LOOSEBOARD' : 'US_TRUCK', deliveryMode: 'SELF_DELIVERY',
expectedPalletCount: index === 0 ? null : 1,
expectedPalletCount: index === 0 && !complete ? null : 1,
cargo: { destinationKey: destination.destinationKey, cargoName: `UI验收意向-${index + 1}`,
packageCount: index === 0 ? null : 1, palletSpaces: index === 0 ? null : 1, weightKg: 100, volumeCbm: 1,
packageCount: index === 0 && !complete ? null : 1, palletSpaces: index === 0 && !complete ? null : 1, weightKg: 100, volumeCbm: 1,
priceBasis: 'PALLET', unitPrice: 30, currency: 'CNY', contactName: 'UI验收联系人', contactPhone: '19900000000', remark: '独立UI验收草稿,不占位' },
idempotencyKey: key }
}
export async function runUiFixture(config, { env = process.env, outputRoot = ROOT, readers,
prepare = createFleetCrossCustomerFixture, contextFactory = options => new AcceptanceRun(options) } = {}) {
export async function runUiFixture(config, dependencies = {}) {
demand(config.environment === 'TEST', 'EXPLICIT_TEST_FIXTURE_CONFIGURATION_REQUIRED')
return runUiFixtureCore(config, dependencies, false)
}
export async function runGrayUiFixture(config, dependencies = {}) {
demand(config.environment === 'GRAY', 'EXPLICIT_GRAY_UI_FIXTURE_CONFIGURATION_REQUIRED')
demand(Object.keys(dependencies).every(key => ['env', 'outputRoot'].includes(key)), 'GRAY_UI_CANNOT_OVERRIDE_EVIDENCE_OR_LIFECYCLE')
return runUiFixtureCore(config, dependencies, true)
}
async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, readers,
prepare = createFleetCrossCustomerFixture, contextFactory = options => new AcceptanceRun(options) } = {}, gray = false) {
if (!config.execute) return uiFixturePlan(config)
demand(config.environment === 'TEST' && env.CHONGCHONG_ENV === 'test' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)
demand((gray ? ['prod', 'gray'].includes(env.CHONGCHONG_ENV) : env.CHONGCHONG_ENV === 'test') && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)
&& config.runId !== 'plan' && /^[1-9]\d*$/.test(config.warehouseId) && /^[1-9]\d*$/.test(config.destinationWarehouseId)
&& env.CHONGCHONG_ADMIN_USERNAME && env.CHONGCHONG_ADMIN_PASSWORD, 'EXPLICIT_TEST_FIXTURE_CONFIGURATION_REQUIRED')
&& env.CHONGCHONG_ADMIN_USERNAME && env.CHONGCHONG_ADMIN_PASSWORD, `EXPLICIT_${gray ? 'GRAY' : 'TEST'}_FIXTURE_CONFIGURATION_REQUIRED`)
const privateFile = privateCredentialsPath(config.privateCredentialsFile)
const endpoint = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL)
demand(['http:', 'https:'].includes(endpoint.protocol) && ['/api', '/api/'].includes(endpoint.pathname)
&& !endpoint.username && !endpoint.password && !endpoint.hash && !endpoint.search, 'INVALID_TEST_API_BASE')
readers ||= createTestEvidenceReaders(env)
const options = validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']),
environment: 'test', gateway: endpoint.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD,
markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) })
const secretDocument = { schemaVersion: 1, environment: 'TEST', runId: config.runId, status: 'PREPARING', createdAt: new Date().toISOString(),
demand((gray ? endpoint.protocol === 'https:' : ['http:', 'https:'].includes(endpoint.protocol)) && ['/api', '/api/'].includes(endpoint.pathname)
&& !endpoint.username && !endpoint.password && !endpoint.hash && !endpoint.search, 'INVALID_UI_API_BASE')
if (gray) readers = createGrayEvidenceReaders(env)
else readers ||= createTestEvidenceReaders(env)
const reports = join(outputRoot, 'reports'), output = resolve(outputRoot, uiFixturePlan(config).output)
const transport = { gateway: endpoint.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD,
output, markerNamespace: createHash('sha256').update(`${config.environment}:${config.runId}`).digest('hex').slice(0, 12) }
const options = gray ? createFleetPoolGrayOptions(transport) : validateExecutionOptions({
...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), ...transport, environment: 'test' })
const secretDocument = { schemaVersion: 1, environment: config.environment, runtimeProfile: env.CHONGCHONG_ENV, runId: config.runId, status: 'PREPARING', createdAt: new Date().toISOString(),
gatewayOrigin: endpoint.origin, accounts: [], business: {} }
const vault = createPrivateCredentialStore(privateFile, secretDocument)
const reports = join(outputRoot, 'reports'), output = join(reports, `fleet-capacity-pool-ui-${config.runId}`)
const schedule = gray ? grayUiSchedule() : null, draftCount = gray ? 4 : 24, perSales = draftCount / 2
const secrets = Object.entries(env).filter(([key]) => /PASSWORD|TOKEN|SECRET/.test(key)).map(([, value]) => value).filter(Boolean)
const sessions = new Map(), kinds = new Map()
let context, picker, lock, restore, initialized = false, requestNumber = 0, inventoryNumber = 0, report
@@ -129,10 +174,12 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
for (const method of ['request', 'requestWithBody']) {
const original = context[method]
context[method] = async function (...args) {
const sequence = ++requestNumber, [verb, path, body] = args
const sequence = ++requestNumber, [verb, path] = args
if (gray) args[2] = applyGrayUiTransportWindow(verb, path, args[2], schedule)
const body = args[2]
if (verb === 'POST' && path === `${IAM}/users` && body?.username && body.password) {
remember({ kind: kinds.get(body.username) || (body.username.startsWith('pda_overseas_') ? 'FIXTURE_PDA' : 'FIXTURE_USER'),
username: body.username, password: body.password, roleIds: body.roleIds, status: 'PREPARED' })
username: body.username, password: body.password, roleIds: body.roleIds, warehouseId: body.overseasWarehouseId || null, status: 'PREPARED' })
}
save(`requests/${sequence}.json`, { stage: 'INTENT', sequence, method: verb, path, body: body instanceof FormData ? '[MULTIPART]' : body })
const response = await original.apply(this, args)
@@ -184,10 +231,19 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
check('Existing producer created exactly two untouched customer dispatches', fixture.members.length === 2 && trip)
let detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`)
check('Owned fixture vehicle has actual 30-space capacity', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2)
// Keep UI logins useful beyond the producer's short smoke-test window, before its existing six-hour delivery ETA.
// GRAY delivery ETA was set on the producer's real transfer command, so all four times stay ordered.
detail = await request('PUT', `${tripPath}/plan`, { recordVersion: detail.trip.recordVersion,
plannedDepartureAt: new Date(Date.now() + 4 * 3600000).toISOString(), expectedDriverEndAt: detail.trip.expectedDriverEndAt,
stops: detail.trip.stops, reason: 'UI验收窗口:四小时后计划发车,不执行发车' })
plannedDepartureAt: schedule?.plannedDepartureAt || new Date(Date.now() + 4 * 3600000).toISOString(),
expectedDriverEndAt: schedule?.expectedDriverEndAt || detail.trip.expectedDriverEndAt,
stops: detail.trip.stops, reason: gray ? '灰度UI验收:统一截止、发车、到站与司机结束时间,不执行发车' : 'UI验收窗口:四小时后计划发车,不执行发车' })
if (gray) {
check('GRAY trip departure and driver end follow the explicit UI window', Date.parse(detail.trip.plannedDepartureAt) === Date.parse(schedule.plannedDepartureAt)
&& Date.parse(detail.trip.expectedDriverEndAt) === Date.parse(schedule.expectedDriverEndAt))
for (const member of fixture.members) {
const delivery = await request('GET', `${API}/delivery-orders/${member.deliveryNo}?customerId=${member.customerId}`)
check('Original customer delivery ETA follows the same valid UI window', delivery.order.plannedDeliveryAt === schedule.plannedDeliveryAt)
}
}
plan = await request('GET', `${tripPath}/capacity-plan`)
await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash,
pallets: controlledPalletMeasurements(plan.cargo, detail.trip.stops).map(row => ({ ...row, palletSpaces: 9 })), reason: '仅UI验收:自有计划18/总30,不作为实际发车计量' })
@@ -195,7 +251,7 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
const key = suffix => createHash('sha256').update(`${context.marker}:UI:${suffix}`).digest('hex')
for (const kind of ['SALES_A', 'SALES_B', 'MANAGER']) {
const suffix = key(kind).slice(0, 16), username = `pool_ui_${kind.toLowerCase()}_${suffix}`
const expected = kind === 'MANAGER' ? [PREFIX + 'manage'] : ['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(name => PREFIX + name)
const expected = uiActorPermissions(kind, gray)
const candidates = expected.map(code => permissions.filter(row => row.permissionCode === code && row.status === 'ACTIVE'))
check('Exact fixture permissions are installed', candidates.every(rows => rows.length === 1 && rows[0].id))
const role = await request('POST', `${IAM}/roles`, { roleCode: `POOL_UI_${kind}_${suffix}`.toUpperCase(), roleName: `${context.marker}-${kind}`, description: '仅本轮UI验收账号' })
@@ -213,28 +269,33 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
}
detail = await request('GET', tripPath)
const draft = await request('POST', POOL, { tripNo: fixture.tripNo, tripRecordVersion: detail.trip.recordVersion,
cutoffAt: new Date(Math.min(Date.now() + 3 * 3600000, Date.parse(detail.trip.plannedDepartureAt) - 60000)).toISOString(),
cutoffAt: schedule?.cutoffAt || new Date(Math.min(Date.now() + 3 * 3600000, Date.parse(detail.trip.plannedDepartureAt) - 60000)).toISOString(),
contactName: 'UI验收调度', contactPhone: '19900000000', remark: context.marker, idempotencyKey: key('OFFER') }, actorTokens[2])
let offer = (await request('POST', `${POOL}/${draft.offer.offerNo}/publish`, { tripRecordVersion: draft.offer.tripRecordVersion,
offerRecordVersion: draft.offer.recordVersion, idempotencyKey: key('PUBLISH') }, actorTokens[2])).offer
check('OPEN UI offer is available with exactly 12 planned spaces', offer.status === 'OPEN' && offer.canCreateInquiry && offer.canConfirm
&& offer.destinations.length === 1 && Number(offer.destinations[0].remaining.palletSpaces) === 12)
proof.offerNo = offer.offerNo; proof.tripNo = fixture.tripNo
for (let index = 0; index < 24; index++) {
const owner = index < 12 ? 0 : 1, member = fixture.members[owner]
const created = await request('POST', `${POOL}/${offer.offerNo}/inquiries`, uiInquiryBody(offer, member.customerId, index, key(`DRAFT-${index}`)), actorTokens[owner])
for (let index = 0; index < draftCount; index++) {
const owner = index < perSales ? 0 : 1, member = fixture.members[owner]
const body = uiInquiryBody(offer, member.customerId, index, key(`DRAFT-${index}`), gray)
if (gray) body.estimatedArrivalAt = new Date(Date.now() + 3600000).toISOString()
const created = await request('POST', `${POOL}/${offer.offerNo}/inquiries`, body, actorTokens[owner])
check('UI inquiry is DRAFT with no commercial order or reservation', created.inquiry.status === 'DRAFT' && !created.inquiry.businessOrderNo
&& !created.inquiry.incomingNo && !created.inquiry.dispatchNo && Number(created.offer.destinations[0].remaining.palletSpaces) === 12)
proof.drafts.push({ bookingNo: created.inquiry.bookingNo, customerId: member.customerId, owner: owner === 0 ? 'SALES_A' : 'SALES_B' })
offer = created.offer
}
const firstPage = await request('GET', `${POOL}/inquiries?page=1&size=20&scope=ALL&offerNo=${offer.offerNo}`, undefined, actorTokens[2])
const secondPage = await request('GET', `${POOL}/inquiries?page=2&size=20&scope=ALL&offerNo=${offer.offerNo}`, undefined, actorTokens[2])
check('Manager can exercise real page two of 24 drafts', Number(firstPage.total) === 24 && firstPage.records.length === 20
&& secondPage.records.length === 4 && new Set([...firstPage.records, ...secondPage.records].map(row => row.bookingNo)).size === 24)
if (gray) check('Manager sees exactly four current GRAY UI drafts', Number(firstPage.total) === 4 && firstPage.records.length === 4)
else {
const secondPage = await request('GET', `${POOL}/inquiries?page=2&size=20&scope=ALL&offerNo=${offer.offerNo}`, undefined, actorTokens[2])
check('Manager can exercise real page two of 24 drafts', Number(firstPage.total) === 24 && firstPage.records.length === 20
&& secondPage.records.length === 4 && new Set([...firstPage.records, ...secondPage.records].map(row => row.bookingNo)).size === 24)
}
for (let owner = 0; owner < 2; owner++) {
const mine = await request('GET', `${POOL}/inquiries?page=1&size=20&scope=MINE&offerNo=${offer.offerNo}`, undefined, actorTokens[owner])
check('Each sales login sees exactly its own twelve drafts', Number(mine.total) === 12 && mine.records.length === 12
check(`Each sales login sees exactly its own ${perSales} drafts`, Number(mine.total) === perSales && mine.records.length === perSales
&& mine.records.every(row => proof.drafts.some(draft => draft.bookingNo === row.bookingNo && draft.owner === (owner === 0 ? 'SALES_A' : 'SALES_B'))))
}
for (const member of fixture.members) {
@@ -256,11 +317,12 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
check('Original real cargo remains unpicked', Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0)
}
const final = await request('GET', `${POOL}/${offer.offerNo}`, undefined, actorTokens[2])
check('UI handoff keeps publication OPEN and 24 drafts untouched', final.offer.status === 'OPEN' && final.offer.canCreateInquiry && Number(final.inquiries.total) === 24)
proof.status = 'OPEN_AND_24_DRAFTS_READY_FOR_UI'
check(`UI handoff keeps publication OPEN and ${draftCount} drafts untouched`, final.offer.status === 'OPEN' && final.offer.canCreateInquiry && Number(final.inquiries.total) === draftCount)
proof.status = `OPEN_AND_${draftCount}_DRAFTS_READY_FOR_UI`
secretDocument.business = { offerNo: proof.offerNo, tripNo: fixture.tripNo, warehouseId: fixture.warehouseId,
destinationWarehouseId: config.destinationWarehouseId, cutoffAt: final.offer.cutoffAt,
plannedDepartureAt: final.offer.plannedDepartureAt, customers: fixture.members.map(row => ({ customerId: row.customerId, incomingNo: row.incomingNo, dispatchNo: row.dispatchNo })) }
plannedDepartureAt: final.offer.plannedDepartureAt, ...(gray ? { uiSchedule: schedule } : {}),
customers: fixture.members.map(row => ({ customerId: row.customerId, incomingNo: row.incomingNo, dispatchNo: row.dispatchNo })) }
secretDocument.status = 'READY_FOR_UI'; writePrivate()
report = { success: true, fixtureReady: true, fullAcceptance: false, proof }
} catch (error) {
@@ -270,7 +332,11 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
try {
if (context) {
const pdaAccount = secretDocument.accounts.find(row => row.kind === 'FIXTURE_PDA' && row.membershipId)
if (picker?.membershipId || pdaAccount?.membershipId) {
if (gray && pdaAccount) {
pdaAccount.status = 'RETAINED_FOR_UI'; pdaAccount.purpose = 'GRAY_WAREHOUSE_BOUND_PDA_H5_ACCEPTANCE'
pdaAccount.warehouseId = config.warehouseId
report.retainedPda = { membershipId: pdaAccount.membershipId, warehouseId: config.warehouseId, purpose: pdaAccount.purpose }
} else if (!gray && (picker?.membershipId || pdaAccount?.membershipId)) {
try {
const previousSteps = context.steps.length
await disablePicker(context, picker?.membershipId ? picker : pdaAccount, 93)
@@ -296,6 +362,7 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
if (!report.success) secretDocument.status = 'FAILED_RETAINED'
writePrivate()
report.privateCredentialsFile = privateFile; report.requestCount = requestNumber; report.inventorySnapshots = inventoryNumber
report.environment = config.environment; report.runtimeProfile = env.CHONGCHONG_ENV
report.output = output
if (initialized) save('report.json', report)
} finally {