#!/usr/bin/env node /** * Client phase-two test-environment acceptance flow. * * The platform account is used only to discover deterministic inbound fixtures. * All phase-two product assertions are executed with the Client account. * * Run directly and enter credentials when prompted: * node scripts/client-phase2-test-env-acceptance.mjs */ import { mkdir, writeFile } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import { resolve } from 'node:path' import { createInterface } from 'node:readline/promises' const args = parseArgs(process.argv.slice(2)) const gateway = trimSlash(args.gateway ?? process.env.CLIENT_PHASE2_TEST_GATEWAY ?? 'http://8.163.30.128:9000') const clientUsername = args['client-username'] ?? process.env.CLIENT_PHASE2_USERNAME ?? await promptText('二期客户端账号', 'merchant_admin') const clientPassword = args['client-password'] ?? process.env.CLIENT_PHASE2_PASSWORD ?? await promptSecret('二期客户端密码') const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERNAME ?? await promptText('平台夹具查询账号', 'platform_admin') const platformPassword = args['platform-password'] ?? process.env.PLATFORM_PASSWORD ?? await promptSecret('平台夹具查询密码') const forcedOrderId = args['order-id'] ?? process.env.CLIENT_PHASE2_ORDER_ID ?? null const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_PHASE2_REPORT_DIR ?? 'reports') const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_PHASE2_TIMEOUT_MS ?? 30_000) if (!clientPassword || !platformPassword) { console.error('Client and platform passwords are required') process.exit(2) } if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { console.error('--timeout-ms must be at least 1000') process.exit(2) } const now = new Date() const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}` const reportPath = resolve(reportDir, `client-phase2-test-env-${marker}.json`) const report = { marker, gateway, generatedAt: now.toISOString(), completedAt: null, success: false, fatal: null, testAccount: { username: clientUsername }, expectedTerminalState: { processStatus: 'WAIT_SPLIT', measureReviewStatus: 'APPROVED', feeStatus: 'CONFIRMED', }, resources: { tenantId: null, terminalInboundId: null, terminalOrderId: null, terminalInboundCode: null, terminalMeasurementId: null, pendingOrderId: null, foreignOrderId: null, measurementImageFileIds: [], weightImageFileIds: [], }, steps: [], } let clientToken let platformToken let terminalFixture let pendingFixture let foreignFixture let platformTerminalDetail let platformMeasurement let dimensionRules = [] let merchantBilling try { await sessionModule() await fixtureDiscoveryModule() await unpublishedVisibilityModule() await terminalBillingModule() await unifiedOrderModule() await isolationModule() report.success = report.steps.every((step) => step.passed) } catch (error) { report.fatal = sanitizeError(error) report.success = false } finally { report.completedAt = new Date().toISOString() await mkdir(reportDir, { recursive: true }) await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') printSummary() } process.exitCode = report.success ? 0 : 1 async function sessionModule() { const clientLogin = await apiStep('phase2-session', 'client login', { method: 'POST', path: '/api/auth/login', body: { username: clientUsername, password: clientPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'client accessToken') }, () => ({ username: clientUsername })) clientToken = clientLogin.json.data.accessToken const profile = await apiStep('phase2-session', 'load current enterprise profile', { method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: clientToken, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.id, 'enterprise id') assertEqual(json.data?.status, 'ACTIVE', 'enterprise status') }, ({ json }) => ({ tenantId: json.data.id, tenantName: json.data.tenantName, status: json.data.status })) report.resources.tenantId = String(profile.json.data.id) await apiStep('phase2-session', 'client has order view permission', { method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: clientToken, }, ({ json }) => { requireSuccess(json) assertTrue(arrayData(json.data).includes('merchant:order:view'), 'merchant:order:view permission is required') }, ({ json }) => ({ permissionCount: arrayData(json.data).length })) const platformLogin = await apiStep('phase2-fixture', 'platform fixture account login', { method: 'POST', path: '/api/auth/login', body: { username: platformUsername, password: platformPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'platform accessToken') }, () => ({ username: platformUsername })) platformToken = platformLogin.json.data.accessToken } async function fixtureDiscoveryModule() { const ownInbounds = await apiStep('phase2-fixture', 'discover current enterprise inbound fixtures', { method: 'GET', path: `/api/platform/inbounds?tenantId=${report.resources.tenantId}&page=1&size=200`, token: platformToken, }, ({ json }) => { requireSuccess(json) assertTrue(Array.isArray(json.data?.records), 'inbound records must be an array') }, ({ json }) => ({ total: json.data?.total, statusCounts: statusCounts(json.data?.records) })) const ownRows = pageRecords(ownInbounds.json.data) terminalFixture = forcedOrderId ? ownRows.find((row) => String(row.orderId) === String(forcedOrderId)) : ownRows.find(isTerminalFixture) if (!terminalFixture) { throw new Error(forcedOrderId ? `Configured orderId=${forcedOrderId} is not visible in tenant ${report.resources.tenantId}` : `Tenant ${report.resources.tenantId} has no WAIT_SPLIT + APPROVED + CONFIRMED fixture`) } assertTerminalFixture(terminalFixture) report.resources.terminalInboundId = String(terminalFixture.id) report.resources.terminalOrderId = String(terminalFixture.orderId) report.resources.terminalInboundCode = terminalFixture.inboundCode pendingFixture = ownRows.find((row) => String(row.orderId) !== report.resources.terminalOrderId && !isTerminalFixture(row)) report.resources.pendingOrderId = pendingFixture == null ? null : String(pendingFixture.orderId) await softApiStep('phase2-fixture', 'terminal state filters find selected inbound', { method: 'GET', path: `/api/platform/inbounds?tenantId=${report.resources.tenantId}` + '&processStatus=WAIT_SPLIT&measureReviewStatus=APPROVED&feeStatus=CONFIRMED&page=1&size=200', token: platformToken, }, ({ json }) => { requireSuccess(json) const row = pageRecords(json.data) .find((item) => String(item.id) === report.resources.terminalInboundId) assertPresent(row, 'terminal inbound filter row') assertTerminalFixture(row) }, ({ json }) => ({ total: json.data?.total, inboundId: report.resources.terminalInboundId })) const detail = await apiStep('phase2-fixture', 'load platform terminal inbound reference detail', { method: 'GET', path: `/api/platform/inbounds/${report.resources.terminalInboundId}?tenantId=${report.resources.tenantId}`, token: platformToken, }, ({ json }) => { requireSuccess(json) assertEqual(String(json.data?.summary?.id), report.resources.terminalInboundId, 'inbound id') assertEqual(String(json.data?.summary?.orderId), report.resources.terminalOrderId, 'order id') assertTerminalFixture(json.data?.summary) }, ({ json }) => ({ inboundId: json.data.summary.id, orderId: json.data.summary.orderId, processStatus: json.data.summary.processStatus, measureReviewStatus: json.data.summary.measureReviewStatus, feeStatus: json.data.summary.feeStatus, })) platformTerminalDetail = detail.json.data const dimensionConfig = await apiStep('phase2-measurement-fixture', 'load four customer dimension weighting rules', { method: 'GET', path: `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${report.resources.tenantId}`, token: platformToken, }, ({ json }) => { requireSuccess(json) const rules = arrayData(json.data?.rules) assertEqual(rules.length, 4, 'dimension rule count') assertEqual(new Set(rules.map((rule) => rule.boxType)).size, 4, 'dimension box type count') for (const rule of rules) assertDimensionRule(rule) }, ({ json }) => ({ tenantId: json.data?.tenantId, rules: arrayData(json.data?.rules).map((rule) => ({ boxType: rule.boxType, inner: dimensions(rule, 'inner'), edgeAdditionCm: rule.edgeAdditionCm, outer: dimensions(rule, 'outer'), volumeCbm: rule.volumeCbm, version: rule.version, })), })) dimensionRules = dimensionConfig.json.data.rules const measurement = await apiStep('phase2-measurement-fixture', 'load approved terminal measurement detail', { method: 'GET', path: `/api/platform/v1/first-leg/inbounds/${report.resources.terminalInboundId}` + `/measurement?tenantId=${report.resources.tenantId}`, token: platformToken, }, ({ json }) => { requireSuccess(json) assertEqual(String(json.data?.inboundId), report.resources.terminalInboundId, 'measurement inbound id') assertEqual(json.data?.processStatus, 'WAIT_SPLIT', 'measurement process status') assertEqual(json.data?.measureReviewStatus, 'APPROVED', 'measurement review status') assertEqual(json.data?.measurementStatus, 'APPROVED', 'measurement batch status') assertPresent(json.data?.measurementId, 'measurement id') assertTrue(arrayData(json.data?.items).length > 0, 'measurement items must not be empty') assertTrue(arrayData(json.data?.weightResults).length > 0, 'weight results must not be empty') }, ({ json }) => ({ inboundId: json.data?.inboundId, measurementId: json.data?.measurementId, measurementStatus: json.data?.measurementStatus, actualPackageCount: json.data?.actualPackageCount, actualGrossWeightKg: json.data?.actualGrossWeightKg, actualVolumeCbm: json.data?.actualVolumeCbm, volumeWeightKg: json.data?.volumeWeightKg, itemCount: arrayData(json.data?.items).length, weightResultCount: arrayData(json.data?.weightResults).length, })) platformMeasurement = measurement.json.data report.resources.terminalMeasurementId = String(platformMeasurement.measurementId) await localStep('phase2-measurement-fixture', 'carton measurements contain complete traceable data', () => { for (const item of platformMeasurement.items) { assertStringId(item.id, 'measurement item id') assertStringId(item.inboundDetailId, 'measurement inbound detail id') assertPresent(item.itemNo, 'measurement item number') assertPresent(item.boxCode, 'measurement box code') assertPresent(item.machineNo, 'measurement machine number') assertPresent(item.deviceMeasuredAt, 'device measured time') assertStringId(item.intelligentImageFileId, 'measurement image file id') assertTrue(Number(item.packageCount) > 0, 'measurement package count must be positive') assertEqual(Number(item.confirmedPackageCount), Number(item.packageCount), 'confirmed package count') for (const field of ['lengthCm', 'widthCm', 'heightCm', 'unitVolumeCbm', 'totalVolumeCbm']) { assertTrue(Number(item[field]) > 0, `${field} must be positive`) } } }, { itemCount: platformMeasurement.items.length }) await localStep('phase2-measurement-fixture', 'carton volume and package aggregates are consistent', () => { const packageCount = sum(platformMeasurement.items, 'packageCount') const volumeCbm = sum(platformMeasurement.items, 'totalVolumeCbm') const volumeWeightKg = sum(platformMeasurement.items, 'volumeWeightKg') assertNumberEqual(packageCount, platformMeasurement.actualPackageCount, 'measurement package aggregate') assertApproxEqual(volumeCbm, platformMeasurement.actualVolumeCbm, 0.000001, 'measurement volume aggregate') assertApproxEqual(volumeWeightKg, platformMeasurement.volumeWeightKg, 0.001, 'measurement volume weight aggregate') }, { actualPackageCount: platformMeasurement.actualPackageCount, actualVolumeCbm: platformMeasurement.actualVolumeCbm, volumeWeightKg: platformMeasurement.volumeWeightKg, }) await localStep('phase2-measurement-fixture', 'confirmed weight results form an independent weight line', () => { for (const weight of platformMeasurement.weightResults) { assertStringId(weight.id, 'weight result id') assertStringId(weight.fileId, 'weight image file id') assertEqual(weight.recognitionStatus, 'CONFIRMED', 'weight recognition status') assertPresent(weight.confirmedAt, 'weight confirmation time') assertTrue(Number(weight.finalWeightKg) > 0, 'final weight must be positive') } assertApproxEqual(sum(platformMeasurement.weightResults, 'finalWeightKg'), platformMeasurement.actualGrossWeightKg, 0.001, 'measurement gross weight aggregate') }, { actualGrossWeightKg: platformMeasurement.actualGrossWeightKg, weightResultCount: platformMeasurement.weightResults.length, }) await softLocalStep('phase2-measurement-fixture', 'carton billing volume uses current customer dimension weighting', () => { for (const item of platformMeasurement.items) { const expected = resolveWeightedDimensions(item, dimensionRules) assertApproxEqual(item.unitVolumeCbm, expected.unitVolumeCbm, 0.000001, `${item.itemNo} weighted unit volume`) } }, { measurementId: platformMeasurement.measurementId, items: platformMeasurement.items.map((item) => ({ itemNo: item.itemNo, measured: dimensions(item), storedOuter: dimensions(item, 'outer'), unitVolumeCbm: item.unitVolumeCbm, expected: resolveWeightedDimensions(item, dimensionRules), })), }) await apiStep('phase2-client-security', 'client cannot read platform measurement workspace directly', { method: 'GET', path: `/api/platform/v1/first-leg/inbounds/${report.resources.terminalInboundId}` + `/measurement?tenantId=${report.resources.tenantId}`, token: clientToken, }, ({ status, json }) => { assertEqual(status, 403, 'HTTP status') assertTrue(json?.code == null || String(json.code) === 'A0403', `expected absent/A0403 business code, actual=${json?.code}`) }, ({ json }) => ({ rejectionCode: json?.code })) const platformImageFileId = platformMeasurement.items[0]?.intelligentImageFileId await apiStep('phase2-client-security', 'client cannot bypass billing publication to read platform image', { method: 'GET', path: `/api/platform/v1/files/measurements/${report.resources.tenantId}` + `/${report.resources.terminalInboundId}/${platformImageFileId}`, token: clientToken, }, ({ status, json }) => { assertEqual(status, 403, 'HTTP status') assertTrue(json?.code == null || String(json.code) === 'A0403', `expected absent/A0403 business code, actual=${json?.code}`) }, ({ json }) => ({ fileId: platformImageFileId, rejectionCode: json?.code })) const allTerminal = await apiStep('phase2-fixture', 'discover foreign terminal fixture for isolation', { method: 'GET', path: '/api/platform/inbounds?page=1&size=200', token: platformToken, }, ({ json }) => { requireSuccess(json) assertTrue(Array.isArray(json.data?.records), 'terminal fixture records must be an array') }, ({ json }) => ({ total: json.data?.total })) foreignFixture = pageRecords(allTerminal.json.data) .find((row) => isTerminalFixture(row) && String(row.tenantId) !== report.resources.tenantId) report.resources.foreignOrderId = foreignFixture == null ? null : String(foreignFixture.orderId) } async function unpublishedVisibilityModule() { if (!pendingFixture) { recordSkipped('phase2-client-visibility', 'unfinished inbound billing remains unavailable', 'Current enterprise has no non-terminal inbound fixture') return } await apiStep('phase2-client-visibility', 'unfinished inbound billing remains unavailable', { method: 'GET', path: `/api/merchant/v1/first-leg/orders/${pendingFixture.orderId}/inbound-billing`, token: clientToken, }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0404', 'business code') }, ({ json }) => ({ orderId: pendingFixture.orderId, processStatus: pendingFixture.processStatus, feeStatus: pendingFixture.feeStatus, rejectionCode: json?.code, })) await apiStep('phase2-client-visibility', 'unknown order inbound billing is rejected', { method: 'GET', path: '/api/merchant/v1/first-leg/orders/1/inbound-billing', token: clientToken, }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0404', 'business code') }, ({ json }) => ({ orderId: 1, rejectionCode: json?.code })) } async function terminalBillingModule() { const response = await softApiStep('phase2-client-billing', 'terminal inbound billing is visible to client', { method: 'GET', path: `/api/merchant/v1/first-leg/orders/${report.resources.terminalOrderId}/inbound-billing`, token: clientToken, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.inbound?.summary, 'merchant inbound summary') assertPresent(json.data?.latestFee, 'merchant latest fee') assertEqual(String(json.data.inbound.summary.id), report.resources.terminalInboundId, 'inbound id') assertEqual(String(json.data.inbound.summary.orderId), report.resources.terminalOrderId, 'order id') assertTerminalFixture(json.data.inbound.summary) assertEqual(json.data.latestFee.feeStatus, 'CONFIRMED', 'latest fee status') }, ({ json }) => ({ inboundId: json?.data?.inbound?.summary?.id, orderId: json?.data?.inbound?.summary?.orderId, processStatus: json?.data?.inbound?.summary?.processStatus, measureReviewStatus: json?.data?.inbound?.summary?.measureReviewStatus, feeStatus: json?.data?.latestFee?.feeStatus, rejectionCode: json?.code, })) if (!response) { const reason = 'Terminal billing endpoint did not return a visible aggregate' for (const name of [ 'terminal billing aggregate contract', 'published measurement summary matches approved platform snapshot', 'published carton measurements match platform snapshot', 'published carton volumes follow current weighting rules', 'published weight results match confirmed platform weights', 'weight and volume billing lines are independently consistent', 'published fee detail matches confirmed fee snapshot', 'client aggregate excludes platform internal collections', ]) recordSkipped('phase2-client-billing', name, reason) recordSkipped('phase2-client-images', 'published measurement and weight image access', reason) return } merchantBilling = response.json.data await softLocalStep('phase2-client-billing', 'terminal billing aggregate contract', () => { const summary = merchantBilling.inbound.summary const fee = merchantBilling.latestFee assertStringId(summary.id, 'inbound summary id') assertStringId(summary.orderId, 'inbound order id') assertStringId(fee.feeId, 'fee id') assertStringId(fee.inboundId, 'fee inbound id') assertEqual(String(fee.inboundId), String(summary.id), 'fee inbound relation') assertEqual(fee.inboundCode, summary.inboundCode, 'fee inbound code') assertEqual(fee.customerId == null ? null : String(fee.customerId), report.resources.tenantId, 'fee customer isolation') assertTrue(Number(fee.actualWeightKg) > 0, 'actual weight must be positive') assertTrue(Number(fee.actualVolumeCbm) > 0, 'actual volume must be positive') assertTrue(Number(fee.chargeableQuantity) > 0, 'chargeable quantity must be positive') assertTrue(Number(fee.unitPrice) >= 0, 'unit price must not be negative') assertMoneyEqual(Number(fee.subtotalAmount) + Number(fee.additionalAmount), Number(fee.totalAmount), 'fee amount composition') assertEqual(fee.chargeUnit, fee.chargeMode, 'fee charge unit') assertTrue(['KG', 'CBM'].includes(fee.chargeMode), 'charge mode must be KG or CBM') }, { inboundId: report.resources.terminalInboundId, feeId: merchantBilling.latestFee.feeId, feeNo: merchantBilling.latestFee.feeNo, chargeMode: merchantBilling.latestFee.chargeMode, chargeableQuantity: merchantBilling.latestFee.chargeableQuantity, totalAmount: merchantBilling.latestFee.totalAmount, currency: merchantBilling.latestFee.currency, }) await softLocalStep('phase2-client-billing', 'client aggregate excludes platform internal collections', () => { assertEqual(arrayData(merchantBilling.inbound.internalNotes).length, 0, 'merchant internal note count') assertEqual(arrayData(merchantBilling.inbound.statusHistory).length, 0, 'merchant status history count') for (const file of arrayData(merchantBilling.inbound.files)) { assertTrue(['PUBLIC', 'MERCHANT'].includes(file.visibility), `merchant file visibility must be PUBLIC or MERCHANT, actual=${file.visibility}`) } assertEqual(merchantBilling.inbound.publicRemark ?? null, platformTerminalDetail.publicRemark ?? null, 'public remark') }, { internalNoteCount: arrayData(merchantBilling.inbound.internalNotes).length, statusHistoryCount: arrayData(merchantBilling.inbound.statusHistory).length, visibleFileCount: arrayData(merchantBilling.inbound.files).length, }) await softLocalStep('phase2-client-billing', 'published measurement summary matches approved platform snapshot', () => { const summary = merchantBilling.measurementSummary assertPresent(summary?.measurementId, 'measurement id') assertEqual(String(summary.measurementId), String(platformMeasurement.measurementId), 'measurement id') assertEqual(summary.measurementStatus, platformMeasurement.measurementStatus, 'measurement status') assertNumberEqual(summary.actualPackageCount, platformMeasurement.actualPackageCount, 'measurement package count') assertApproxEqual(summary.actualGrossWeightKg, platformMeasurement.actualGrossWeightKg, 0.001, 'measurement gross weight') assertApproxEqual(summary.actualVolumeCbm, platformMeasurement.actualVolumeCbm, 0.000001, 'measurement volume') assertApproxEqual(summary.volumeWeightKg, platformMeasurement.volumeWeightKg, 0.001, 'measurement volume weight') }, { measurementId: merchantBilling.measurementSummary?.measurementId, measurementStatus: merchantBilling.measurementSummary?.measurementStatus, actualPackageCount: merchantBilling.measurementSummary?.actualPackageCount, actualGrossWeightKg: merchantBilling.measurementSummary?.actualGrossWeightKg, actualVolumeCbm: merchantBilling.measurementSummary?.actualVolumeCbm, }) await softLocalStep('phase2-client-billing', 'published carton measurements match platform snapshot', () => { const publishedItems = arrayData(merchantBilling.measurementItems) assertEqual(publishedItems.length, platformMeasurement.items.length, 'published measurement item count') const platformById = new Map(platformMeasurement.items.map((item) => [String(item.id), item])) for (const item of publishedItems) { assertStringId(item.id, 'published measurement item id') const expected = platformById.get(String(item.id)) assertPresent(expected, `platform measurement item ${item.id}`) for (const field of ['packageCount', 'lengthCm', 'widthCm', 'heightCm', 'unitWeightKg', 'totalWeightKg', 'unitVolumeCbm', 'totalVolumeCbm', 'volumeWeightKg']) { assertApproxEqual(item[field], expected[field], 0.000001, `${item.itemNo} ${field}`) } } }, { measurementItemCount: arrayData(merchantBilling.measurementItems).length }) await softLocalStep('phase2-client-billing', 'published carton volumes follow current weighting rules', () => { for (const item of arrayData(merchantBilling.measurementItems)) { const expected = resolveWeightedDimensions(item, dimensionRules) assertApproxEqual(item.unitVolumeCbm, expected.unitVolumeCbm, 0.000001, `${item.itemNo} published weighted unit volume`) } }, { items: arrayData(merchantBilling.measurementItems).map((item) => ({ itemNo: item.itemNo, measured: dimensions(item), unitVolumeCbm: item.unitVolumeCbm, expected: resolveWeightedDimensions(item, dimensionRules), })), }) await softLocalStep('phase2-client-billing', 'published weight results match confirmed platform weights', () => { const publishedWeights = arrayData(merchantBilling.weightResults) assertEqual(publishedWeights.length, platformMeasurement.weightResults.length, 'published weight result count') const platformById = new Map(platformMeasurement.weightResults.map((item) => [String(item.id), item])) for (const weight of publishedWeights) { assertStringId(weight.id, 'published weight result id') const expected = platformById.get(String(weight.id)) assertPresent(expected, `platform weight result ${weight.id}`) assertEqual(String(weight.fileId), String(expected.fileId), 'weight image file id') assertEqual(weight.recognitionStatus, 'CONFIRMED', 'published weight status') assertApproxEqual(weight.finalWeightKg, expected.finalWeightKg, 0.001, 'published final weight') } assertApproxEqual(sum(publishedWeights, 'finalWeightKg'), merchantBilling.measurementSummary.actualGrossWeightKg, 0.001, 'published weight aggregate') }, { weightResultCount: arrayData(merchantBilling.weightResults).length }) await softLocalStep('phase2-client-billing', 'weight and volume billing lines are independently consistent', () => { const summary = merchantBilling.measurementSummary const basis = merchantBilling.billingBasis const fee = merchantBilling.latestFee assertNumberEqual(basis.actualWeightKg, summary.actualGrossWeightKg, 'billing actual weight') assertNumberEqual(basis.actualVolumeCbm, summary.actualVolumeCbm, 'billing actual volume') assertNumberEqual(basis.actualWeightKg, fee.actualWeightKg, 'fee actual weight') assertNumberEqual(basis.actualVolumeCbm, fee.actualVolumeCbm, 'fee actual volume') assertNumberEqual(basis.convertedQuantity, fee.convertedQuantity, 'billing converted quantity') assertNumberEqual(basis.chargeableQuantity, fee.chargeableQuantity, 'billing chargeable quantity') assertTrue(['ACTUAL_WEIGHT', 'VOLUME_WEIGHT', 'EQUAL', 'ACTUAL_VOLUME', 'WEIGHT_CONVERTED_VOLUME'].includes(basis.chargeableBasis), 'chargeable basis must be supported') }, { actualWeightKg: merchantBilling.billingBasis?.actualWeightKg, actualVolumeCbm: merchantBilling.billingBasis?.actualVolumeCbm, convertedQuantity: merchantBilling.billingBasis?.convertedQuantity, chargeableQuantity: merchantBilling.billingBasis?.chargeableQuantity, chargeableBasis: merchantBilling.billingBasis?.chargeableBasis, }) await softLocalStep('phase2-client-billing', 'published fee detail matches confirmed fee snapshot', () => { const detail = merchantBilling.publishedFeeDetail const fee = merchantBilling.latestFee assertPresent(detail?.id, 'published fee detail id') assertEqual(String(detail.inboundId), report.resources.terminalInboundId, 'published fee inbound id') assertEqual(String(detail.sourceFeeId), String(fee.feeId), 'published source fee id') assertEqual(Number(detail.sourceFeeVersion), Number(fee.feeVersion), 'published source fee version') assertEqual(String(detail.customerId), report.resources.tenantId, 'published fee customer id') assertEqual(detail.publicationStatus, 'PUBLISHED', 'published fee publication status') assertPresent(detail.publishedAt, 'published fee time') assertMoneyEqual(detail.amount, fee.totalAmount, 'published fee amount') assertNumberEqual(detail.chargeableQuantity, fee.chargeableQuantity, 'published chargeable quantity') assertEqual(detail.chargeUnit, fee.chargeUnit, 'published charge unit') }, { publishedFeeDetailId: merchantBilling.publishedFeeDetail?.id, sourceFeeId: merchantBilling.publishedFeeDetail?.sourceFeeId, publicationStatus: merchantBilling.publishedFeeDetail?.publicationStatus, amount: merchantBilling.publishedFeeDetail?.amount, }) await softLocalStep('phase2-client-billing', 'measurement and billing basis are internally consistent', () => { const basis = merchantBilling.billingBasis const fee = merchantBilling.latestFee assertNumberEqual(basis.actualWeightKg, fee.actualWeightKg, 'billing actual weight') assertNumberEqual(basis.actualVolumeCbm, fee.actualVolumeCbm, 'billing actual volume') assertNumberEqual(basis.convertedQuantity, fee.convertedQuantity, 'billing converted quantity') assertNumberEqual(basis.chargeableQuantity, fee.chargeableQuantity, 'billing chargeable quantity') }, { measurementItemCount: arrayData(merchantBilling.measurementItems).length, weightResultCount: arrayData(merchantBilling.weightResults).length, chargeableBasis: merchantBilling.billingBasis?.chargeableBasis, }) const measurementImages = arrayData(merchantBilling.measurementImages) const weightImages = arrayData(merchantBilling.weightImages) report.resources.measurementImageFileIds = measurementImages.map((item) => String(item.fileId)) report.resources.weightImageFileIds = weightImages.map((item) => String(item.fileId)) const images = [...measurementImages, ...weightImages] if (images.length === 0) { recordSkipped('phase2-client-images', 'published measurement and weight image access', 'Terminal billing aggregate contains no image references') } else { for (const image of images.slice(0, 4)) { await apiStep('phase2-client-images', `download ${image.imageType} image ${image.fileId}`, { method: 'GET', path: apiPath(image.previewUrl), token: clientToken, responseType: 'bytes', }, ({ status, bytes, headers }) => { assertEqual(status, 200, 'HTTP status') assertTrue(bytes?.length > 0, 'downloaded image must not be empty') assertTrue((headers.get('content-type') ?? '').startsWith('image/'), 'download content type must be an image') }, ({ bytes, headers }) => ({ fileId: image.fileId, imageType: image.imageType, sizeBytes: bytes.length, contentType: headers.get('content-type'), })) } } await apiStep('phase2-client-images', 'unreferenced inbound image is rejected', { method: 'GET', path: `/api/merchant/v1/first-leg/orders/${report.resources.terminalOrderId}/inbound-billing/images/1`, token: clientToken, }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0404', 'business code') }, ({ json }) => ({ fileId: 1, rejectionCode: json?.code })) } async function unifiedOrderModule() { const orderNo = terminalFixture.orderNo await softApiStep('phase2-unified-orders', 'unified order list exposes phase-two terminal state', { method: 'GET', path: `/api/merchant/v1/orders?page=1&size=50&orderNo=${encodeURIComponent(orderNo)}`, token: clientToken, }, ({ json }) => { requireSuccess(json) const row = pageRecords(json.data) .find((item) => String(item.orderId) === report.resources.terminalOrderId) assertPresent(row, 'unified terminal order row') assertEqual(row.mainStatus, 'INBOUNDED', 'unified main status') assertEqual(String(row.inboundId), report.resources.terminalInboundId, 'unified inbound id') assertEqual(row.measurement?.reviewStatus, 'APPROVED', 'unified measurement review status') assertTrue(Number(row.actualGrossWeightKg) > 0, 'unified actual gross weight must be positive') assertTrue(Number(row.actualVolumeCbm) > 0, 'unified actual volume must be positive') }, ({ json }) => { const row = pageRecords(json.data) .find((item) => String(item.orderId) === report.resources.terminalOrderId) return { orderId: row?.orderId, mainStatus: row?.mainStatus, inboundId: row?.inboundId, measurementReviewStatus: row?.measurement?.reviewStatus, actualGrossWeightKg: row?.actualGrossWeightKg, actualVolumeCbm: row?.actualVolumeCbm } }) await softApiStep('phase2-unified-orders', 'unified order detail exposes phase-two summary and timeline', { method: 'GET', path: `/api/merchant/v1/orders/${report.resources.terminalOrderId}`, token: clientToken, }, ({ json }) => { requireSuccess(json) assertEqual(String(json.data?.summary?.orderId), report.resources.terminalOrderId, 'unified order id') assertEqual(json.data?.summary?.mainStatus, 'INBOUNDED', 'unified main status') assertEqual(String(json.data?.summary?.inboundId), report.resources.terminalInboundId, 'unified inbound id') assertEqual(json.data?.summary?.measurement?.reviewStatus, 'APPROVED', 'unified measurement review status') assertTrue(Array.isArray(json.data?.timeline), 'unified timeline must be an array') }, ({ json }) => ({ orderId: json.data?.summary?.orderId, mainStatus: json.data?.summary?.mainStatus, timelineCount: arrayData(json.data?.timeline).length, })) } async function isolationModule() { if (!foreignFixture) { recordSkipped('phase2-isolation', 'cross-enterprise terminal billing is rejected', 'No foreign WAIT_SPLIT + APPROVED + CONFIRMED fixture is visible to the platform account') return } await apiStep('phase2-isolation', 'cross-enterprise terminal billing is rejected', { method: 'GET', path: `/api/merchant/v1/first-leg/orders/${foreignFixture.orderId}/inbound-billing`, token: clientToken, }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`) }, ({ json }) => ({ orderId: foreignFixture.orderId, tenantId: foreignFixture.tenantId, rejectionCode: json?.code, })) } async function apiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) { const startedAt = new Date().toISOString() const started = Date.now() let response try { response = await httpRequest(request) validate(response) report.steps.push(makeStep(module, name, true, startedAt, started, response, evidenceBuilder(response))) console.log(`PASS [${module}] ${name}`) return response } catch (error) { let evidence = null if (response) { try { evidence = evidenceBuilder(response) } catch { evidence = defaultEvidence(response) } } const step = makeStep(module, name, false, startedAt, started, response, evidence) step.error = sanitizeError(error) report.steps.push(step) console.error(`FAIL [${module}] ${name}: ${error.message}`) throw error } } async function softApiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) { try { return await apiStep(module, name, request, validate, evidenceBuilder) } catch { return null } } async function localStep(module, name, validate, evidence = null) { const startedAt = new Date().toISOString() const started = Date.now() try { validate() report.steps.push(makeStep(module, name, true, startedAt, started, null, evidence)) console.log(`PASS [${module}] ${name}`) } catch (error) { const step = makeStep(module, name, false, startedAt, started, null, evidence) step.error = sanitizeError(error) report.steps.push(step) console.error(`FAIL [${module}] ${name}: ${error.message}`) throw error } } async function softLocalStep(module, name, validate, evidence = null) { try { await localStep(module, name, validate, evidence) } catch { } } async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) { const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-phase2-${randomUUID()}`, ...headers } if (token) requestHeaders.Authorization = `Bearer ${token}` let requestBody = body if (body != null && !(body instanceof FormData)) { requestHeaders['Content-Type'] = 'application/json' requestBody = JSON.stringify(body) } const response = await fetch(`${gateway}${path}`, { method, headers: requestHeaders, body: requestBody, signal: AbortSignal.timeout(timeoutMs), }) const result = { status: response.status, headers: response.headers, json: null, bytes: null, text: null } if (responseType === 'bytes') { result.bytes = new Uint8Array(await response.arrayBuffer()) return result } const text = await response.text() result.text = text if (text) { try { result.json = JSON.parse(text) } catch { throw new Error(`Expected JSON but received ${text.slice(0, 200)}`) } } return result } function makeStep(module, name, passed, startedAt, started, response, evidence) { return { module, name, passed, skipped: false, startedAt, durationMs: Date.now() - started, http: response?.status ?? null, code: response?.json?.code == null ? null : String(response.json.code), message: response?.json?.message ?? null, requestId: headerOrBody(response, 'x-request-id', 'requestId'), traceId: headerOrBody(response, 'x-trace-id', 'traceId'), evidence, } } function recordSkipped(module, name, reason) { report.steps.push({ module, name, passed: true, skipped: true, startedAt: new Date().toISOString(), durationMs: 0, http: null, code: null, message: reason, requestId: null, traceId: null, evidence: null, }) console.log(`SKIP [${module}] ${name}: ${reason}`) } function requireSuccess(json) { assertPresent(json, 'response body') assertEqual(String(json.code), '200', 'business code') } function isTerminalFixture(row) { return row?.processStatus === 'WAIT_SPLIT' && row?.measureReviewStatus === 'APPROVED' && row?.feeStatus === 'CONFIRMED' } function assertTerminalFixture(row) { assertPresent(row, 'terminal inbound fixture') assertEqual(row.processStatus, 'WAIT_SPLIT', 'process status') assertEqual(row.measureReviewStatus, 'APPROVED', 'measurement review status') assertEqual(row.feeStatus, 'CONFIRMED', 'fee status') } function assertDimensionRule(rule) { assertPresent(rule?.boxType, 'dimension box type') assertTrue(Number(rule.version) >= 0, `${rule.boxType} version must not be negative`) for (const field of ['innerLengthCm', 'innerWidthCm', 'innerHeightCm', 'edgeAdditionCm', 'outerLengthCm', 'outerWidthCm', 'outerHeightCm', 'volumeCbm']) { assertTrue(Number(rule[field]) > 0, `${rule.boxType} ${field} must be positive`) } const edgePair = Number(rule.edgeAdditionCm) * 2 assertApproxEqual(rule.outerLengthCm, Number(rule.innerLengthCm) + edgePair, 0.000001, `${rule.boxType} outer length`) assertApproxEqual(rule.outerWidthCm, Number(rule.innerWidthCm) + edgePair, 0.000001, `${rule.boxType} outer width`) assertApproxEqual(rule.outerHeightCm, Number(rule.innerHeightCm) + edgePair, 0.000001, `${rule.boxType} outer height`) assertApproxEqual(rule.volumeCbm, Number(rule.outerLengthCm) * Number(rule.outerWidthCm) * Number(rule.outerHeightCm) / 1_000_000, 0.0005, `${rule.boxType} configured volume`) } function resolveWeightedDimensions(item, rules) { const measured = [Number(item.lengthCm), Number(item.widthCm), Number(item.heightCm)] .sort((left, right) => right - left) const candidates = [...arrayData(rules)].sort((left, right) => Number(left.innerLengthCm) * Number(left.innerWidthCm) * Number(left.innerHeightCm) - Number(right.innerLengthCm) * Number(right.innerWidthCm) * Number(right.innerHeightCm)) const rule = candidates.find((candidate) => { const inner = [Number(candidate.innerLengthCm), Number(candidate.innerWidthCm), Number(candidate.innerHeightCm)].sort((left, right) => right - left) return measured.every((value, index) => value <= inner[index]) }) if (!rule) { const rawVolume = measured.reduce((total, value) => total * value, 1) / 1_000_000 return { boxType: null, ruleVersion: null, outer: dimensions(item), unitVolumeCbm: round(rawVolume, 3) } } const edgePair = Number(rule.edgeAdditionCm) * 2 const outer = [Number(rule.innerLengthCm) + edgePair, Number(rule.innerWidthCm) + edgePair, Number(rule.innerHeightCm) + edgePair] return { boxType: rule.boxType, ruleVersion: rule.version, outer, unitVolumeCbm: round(outer.reduce((total, value) => total * value, 1) / 1_000_000, 3), } } function dimensions(value, prefix = '') { const field = (name) => `${prefix}${prefix ? name[0].toUpperCase() + name.slice(1) : name}` return [value?.[field('lengthCm')] ?? null, value?.[field('widthCm')] ?? null, value?.[field('heightCm')] ?? null] } function sum(values, field) { return arrayData(values).reduce((total, value) => total + Number(value?.[field] ?? 0), 0) } function round(value, scale) { const factor = 10 ** scale return Math.round((Number(value) + Number.EPSILON) * factor) / factor } function statusCounts(values) { const counts = {} for (const row of arrayData(values)) { const key = `${row.processStatus ?? 'UNKNOWN'}|${row.measureReviewStatus ?? 'NONE'}|${row.feeStatus ?? 'NONE'}` counts[key] = (counts[key] ?? 0) + 1 } return counts } function assertStringId(value, label) { assertPresent(value, label) assertEqual(typeof value, 'string', `${label} type`) assertTrue(/^\d+$/.test(value), `${label} must contain only digits`) } function assertNumberEqual(actual, expected, label) { assertEqual(Number(actual), Number(expected), label) } function assertApproxEqual(actual, expected, tolerance, label) { const actualNumber = Number(actual) const expectedNumber = Number(expected) if (!Number.isFinite(actualNumber) || !Number.isFinite(expectedNumber) || Math.abs(actualNumber - expectedNumber) > tolerance) { throw new Error(`${label}: expected=${format(expected)} actual=${format(actual)}`) } } function assertMoneyEqual(actual, expected, label) { assertEqual(Math.round(Number(actual) * 100), Math.round(Number(expected) * 100), label) } function defaultEvidence({ json }) { if (!json || json.data == null) return null if (typeof json.data !== 'object') return { data: json.data } return null } function pageRecords(data) { if (Array.isArray(data)) return data return arrayData(data?.records ?? data?.items ?? data?.content) } function arrayData(value) { return Array.isArray(value) ? value : [] } function headerOrBody(response, headerName, bodyName) { return response?.headers?.get(headerName) ?? response?.json?.[bodyName] ?? response?.json?.data?.[bodyName] ?? null } function assertEqual(actual, expected, label) { if (actual !== expected) throw new Error(`${label}: expected=${format(expected)} actual=${format(actual)}`) } function assertPresent(value, label) { if (value == null || value === '') throw new Error(`${label} is required`) } function assertTrue(value, message) { if (!value) throw new Error(message) } function apiPath(value) { const url = new URL(String(value), `${gateway}/`) return `${url.pathname}${url.search}` } function printSummary() { const failed = report.steps.filter((step) => !step.passed) const skipped = report.steps.filter((step) => step.skipped) console.log(JSON.stringify({ success: report.success, marker, passed: report.steps.filter((step) => step.passed && !step.skipped).length, failed: failed.length, skipped: skipped.length, reportPath, }, null, 2)) } function parseArgs(values) { const parsed = {} for (let index = 0; index < values.length; index += 1) { const value = values[index] if (!value.startsWith('--')) continue const [rawKey, inlineValue] = value.slice(2).split('=', 2) parsed[rawKey] = inlineValue ?? values[index + 1] if (inlineValue == null) index += 1 } return parsed } function compactTimestamp(value) { return value.toISOString().replace(/[-:TZ.]/g, '').slice(0, 14) } function trimSlash(value) { return String(value).replace(/\/+$/, '') } function format(value) { return typeof value === 'string' ? JSON.stringify(value) : String(value) } function sanitizeError(error) { return { name: error?.name ?? 'Error', message: error?.message ?? String(error) } } async function promptText(label, defaultValue = '') { if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultValue const reader = createInterface({ input: process.stdin, output: process.stdout }) try { const suffix = defaultValue ? ` [${defaultValue}]` : '' const value = await reader.question(`${label}${suffix}: `) return value.trim() || defaultValue } finally { reader.close() } } async function promptSecret(label) { if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined const input = process.stdin const output = process.stdout output.write(`${label}: `) input.setRawMode(true) input.resume() input.setEncoding('utf8') let value = '' try { for await (const chunk of input) { for (const character of chunk) { if (character === '\r' || character === '\n') { output.write('\n') return value } if (character === '\u0003') throw new Error('Input cancelled') if (character === '\u007f' || character === '\b') { if (value.length > 0) value = value.slice(0, -1) continue } value += character } } return value } finally { input.setRawMode(false) input.pause() } }