1010 lines
46 KiB
JavaScript
1010 lines
46 KiB
JavaScript
#!/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.grossWeightKg) > 0, 'gross weight must be positive')
|
|
assertTrue(Number(weight.netWeightKg) > 0, 'net weight must be positive')
|
|
}
|
|
assertApproxEqual(sum(platformMeasurement.weightResults, 'grossWeightKg'),
|
|
platformMeasurement.actualGrossWeightKg, 0.001, 'measurement gross weight aggregate')
|
|
assertApproxEqual(sum(platformMeasurement.weightResults, 'netWeightKg'),
|
|
platformMeasurement.actualNetWeightKg, 0.001, 'measurement net 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.actualNetWeightKg, platformMeasurement.actualNetWeightKg, 0.001,
|
|
'measurement net 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',
|
|
'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.grossWeightKg, expected.grossWeightKg, 0.001, 'published gross weight')
|
|
assertApproxEqual(weight.netWeightKg, expected.netWeightKg, 0.001, 'published net weight')
|
|
}
|
|
assertApproxEqual(sum(publishedWeights, 'grossWeightKg'),
|
|
merchantBilling.measurementSummary.actualGrossWeightKg, 0.001, 'published weight aggregate')
|
|
assertApproxEqual(sum(publishedWeights, 'netWeightKg'),
|
|
merchantBilling.measurementSummary.actualNetWeightKg, 0.001, 'published net 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.actualNetWeightKg, '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, 'WAIT_SPLIT', '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, 'WAIT_SPLIT', '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()
|
|
}
|
|
}
|