This commit is contained in:
2026-09-04 10:51:25 +08:00
parent 4ca5a57e6a
commit 7a0e158b51
13 changed files with 561 additions and 30 deletions
+12
View File
@@ -64,6 +64,7 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套
|---|---|---|
| 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 |
| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 |
| 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 |
| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 二期入库、测量、费用和权限专项;写测试数据 |
| 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 |
| 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` |
@@ -101,6 +102,7 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套
|---|---|---|---|
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 |
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 |
| Client 快速下单定向 HTTP 验收 | `scripts/client-quick-order-test-env-acceptance.mjs` | 使用 `MERCHANT_TEST_USERNAME/PASSWORD` 对应客户创建快速下单草稿 | 真实调用 `/api/merchant/v1/orders/first-leg/batch`,覆盖 FBA、海外仓、私人地址、逐行失败、详情归属与幂等重放;只创建草稿,不提交、不审核 |
| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用 `MERCHANT_TEST_USERNAME/PASSWORD` 配置的客户账号创建指定数量订单 | 使用 `ADMIN_TEST_GATEWAY`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 |
| Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 |
| 人工分柜至 LOADED 分段验收 | `scripts/admin-test-env-acceptance.mjs` 的 `manual-*-fixture/verify` 可选模块;步骤和参数见 `scripts/admin-test-env-modules/README.md` | fixture 通过正式 HTTP 准备本轮数据,verify 只读核验;不进入默认全量 | `WAIT_SPLIT → 人工分柜 → 推送预装 → 订舱关联/计划确认 → PDA 领取与原子 batch → 五类证据/完成 → LOADED` |
@@ -109,6 +111,16 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套
推荐顺序:部署服务 → Admin 全模块 HTTP 验收 → Client 完整 HTTP 链路验收 → 前端真实页面调用和样式验收 → PDA 页面/设备验收。任何本地单测通过都不能替代 HTTP 全量验收。
## Client 快速下单定向验收
快速下单页面使用独立批量草稿接口,不等同于 Excel 批量导入。脚本按 Client 页面当前真实请求构造最小草稿,包含页面只要求 SKU 和箱数、其余货物字段为空值或零值的行为,用于及时发现前后端契约漂移。它会使用测试客户创建 FBA、海外仓和私人地址草稿,并验证无权限请求、单行失败隔离、详情客户归属与同幂等键重放。
```powershell
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/client-quick-order-test-env-acceptance.mjs
```
报告写入 `reports/client-quick-order-test-env-*.json`。脚本不提交、不审核、不取消订单,成功草稿保留为可丢弃测试数据;真实 HTTP 仅在当前轮明确授权测试环境验收后执行。
## 固定客户待审核订单
需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。Gateway 统一读取 `ADMIN_TEST_GATEWAY`,客户账号统一读取 `MERCHANT_TEST_USERNAME/PASSWORD`;数量可通过 `--count` 或 `PENDING_REVIEW_ORDER_COUNT` 设置,默认 50。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。
+31 -2
View File
@@ -151,8 +151,8 @@ Assert-Contains $clientAgents '中文批量导入' 'Client 批量导入入口'
Assert-Contains $firstlegAgents '客户四档箱规与默认算法' 'Firstleg 客户箱规入口'
Assert-Contains $firstlegAgents '`ADD/MULTIPLY`' 'Firstleg 箱规算法约束'
Assert-Contains $adminAgents '客户箱规策略与计价主数据' 'Admin 箱规计费入口'
Assert-Contains $backendAgents '全局智能装柜候选、柜次与箱数分配' '后端全局智能装柜路由'
Assert-Contains $firstlegAgents '同柜不可拆组必须成员完整、满箱且作为单个分页与分配单元' 'Firstleg 智能装柜组分页边界'
Assert-Contains $backendAgents '全局智能装柜候选、柜次、占位与移除' '后端全局智能装柜路由'
Assert-Contains $firstlegAgents '必须成员完整、满箱并作为单个分页与分配单元' 'Firstleg 智能装柜组分页边界'
Assert-Contains $adminAgents 'GlobalSmartLoadingView.vue' 'Admin 全局智能装柜入口'
Assert-Contains $financeAgents 'StatementLine.inboundCode' 'Finance 账单入仓码字段'
Assert-Contains $clientAgents '账单行只显示服务端 `inboundCode`' 'Client 账单入仓码联查边界'
@@ -189,6 +189,35 @@ Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBM
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : summary.mainStatus()' 'Order 待审核主状态优先'
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "APPROVE_ORDER" : summary.nextAction()' 'Order 待审核动作优先'
# 智能装柜已扩展到四态候选、Order 待审核补齐、提前占位与未拣整批移除;费用确认和会话恢复也必须可定向命中。
$pendingReviewOrderService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java')
$merchantInboundBillingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java')
$clientSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/session.ts')
$clientHttp = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/http.ts')
Assert-Contains $rootAgents '`PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`' '根智能装柜四态候选'
Assert-Contains $rootAgents '能否加入以服务端 `canAssign` 为准' '根智能装柜资格边界'
Assert-Contains $rootAgents '人工加入允许跨渠道,智能推荐仍按柜次渠道' '根智能装柜渠道边界'
Assert-Contains $backendAgents '/pending-review-assignments' '后端待审核装柜占位入口'
Assert-Contains $backendAgents '/unpicked-items' '后端未拣整批移除入口'
Assert-Contains $firstlegAgents '任一成员已拣则整组保留' 'Firstleg 同柜组整批移除边界'
Assert-Contains $orderAgents 'PendingReviewOrderQueryService.java' 'Order 待审核装柜候选入口'
Assert-Contains $adminAgents '按钮资格以服务端 `canAssign` 为准' 'Admin 智能装柜资格边界'
Assert-Contains $firstlegOpenApi '未传或空数组表示 PENDING_REVIEW、WAIT_MEASURE、WAIT_SPLIT、' '智能装柜四态 OpenAPI'
Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/pending-review-assignments:' '待审核占位 OpenAPI'
Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/unpicked-items:' '未拣整批移除 OpenAPI'
Assert-Contains $pendingReviewOrderService 'OrderStatus.SUBMITTED' 'Order 待审核候选状态事实'
Assert-Contains $rootAgents '客户入库结果与头程费用确认' '根客户入库费用确认入口'
Assert-Contains $firstlegAgents 'expectedFeeVersion' 'Firstleg 客户费用版本确认'
Assert-Contains $clientAgents 'components/InboundBillingPanel.vue' 'Client 入库费用确认入口'
Assert-Contains $firstlegOpenApi '/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing/confirm:' '客户费用确认 OpenAPI'
Assert-Contains $merchantInboundBillingService 'pricing.confirmByCustomer' 'Firstleg 客户费用确认服务'
Assert-Contains $clientAgents '“记住账号”只记登录标识' 'Client 记住账号边界'
Assert-Contains $clientSession 'localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)' 'Client 刷新令牌持久化'
Assert-Contains $clientHttp "'/auth/refresh'" 'Client 公开刷新入口'
Assert-Path 'baiyue-firstleg/order/src/main/java/com/baiyue/order/controller/InternalPendingReviewOrderController.java'
Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java'
Assert-Path 'apps/client-web/src/modules/first-leg/components/InboundBillingPanel.vue'
# 企业联系信息与账号登录标识已经解耦,资质审核不得重新把法人身份证设为必填。
$authAgents = Read-RequiredText (Join-Path $backendRoot 'auth/AGENTS.md')
$iamAgents = Read-RequiredText (Join-Path $backendRoot 'iam/AGENTS.md')
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
const source = await readFile(new URL('./client-quick-order-test-env-acceptance.mjs', import.meta.url), 'utf8')
test('quick-order HTTP acceptance uses the same endpoint and skeletal cargo payload as Client', () => {
assert.match(source, /\/api\/merchant\/v1\/orders\/first-leg\/batch/)
assert.match(source, /cargoName: '', packageType: 'CARTON', packageCount: 2, grossWeightKg: 0, volumeCbm: 0/)
assert.match(source, /destinationType: 'FBA'/)
assert.match(source, /destinationType: 'OVERSEAS'/)
assert.match(source, /destinationType: 'PRIVATE'/)
assert.match(source, /addresses\?addressType=PRIVATE_DESTINATION/)
assert.match(source, /privateDestinationAddressId: String\(privateAddress\.id\)/)
})
test('quick-order HTTP acceptance verifies partial failure, detail ownership, and idempotent replay', () => {
assert.match(source, /expected succeeded=3/)
assert.match(source, /invalid channel row must fail independently/)
assert.match(source, /draft must belong to the authenticated enterprise/)
assert.match(source, /same idempotency key replays the same quick-order drafts/)
assert.match(source, /idempotent replay returned different order IDs/)
})
@@ -0,0 +1,270 @@
#!/usr/bin/env node
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const args = parseArgs(process.argv.slice(2))
if (args.help) {
console.log('Usage: node scripts/client-quick-order-test-env-acceptance.mjs [--report-dir <path>] [--timeout-ms <ms>]')
process.exit(0)
}
const gateway = trimSlash(process.env.ADMIN_TEST_GATEWAY)
const username = process.env.MERCHANT_TEST_USERNAME
const password = process.env.MERCHANT_TEST_PASSWORD
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000)
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports')
const marker = `${compactTimestamp()}-${randomUUID().slice(0, 8)}`
const reportPath = resolve(reportDir, `client-quick-order-test-env-${marker}.json`)
const report = {
marker,
generatedAt: new Date().toISOString(),
completedAt: null,
success: false,
fatal: null,
resources: { batchKey: null, orderNos: [] },
steps: [],
}
if (!gateway || !username || !password) {
console.error('ADMIN_TEST_GATEWAY, MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD are required')
process.exit(2)
}
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
console.error('--timeout-ms must be at least 1000')
process.exit(2)
}
let token
try {
await step('unauthenticated batch request is rejected', async () => {
const response = await rawRequest('/api/merchant/v1/orders/first-leg/batch', {
method: 'POST', body: [], headers: { 'Idempotency-Key': `quick-order-anonymous-${marker}` },
})
assert([401, 403].includes(response.status), `expected HTTP 401/403, got ${response.status}`)
return { httpStatus: response.status }
})
const login = await step('merchant login', async () => {
const response = await jsonRequest('/api/auth/login', {
method: 'POST', body: { loginId: username, password }, authenticated: false,
})
requireSuccess(response)
assert(response.data?.accessToken, 'accessToken is required')
token = response.data.accessToken
return { authenticated: true }
})
assert(login.authenticated, 'login did not complete')
const profile = await step('load authoritative enterprise profile', async () => {
const response = await jsonRequest('/api/merchant/v1/iam/tenant/profile')
requireSuccess(response)
assert(response.data?.tenantName?.trim(), 'tenantName is required')
return { tenantId: String(response.data.id), tenantName: response.data.tenantName.trim() }
})
const policy = await step('load inbound-code policy', async () => {
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/inbound-code-policy')
requireSuccess(response)
assert(['AUTO', 'LEGACY_MANUAL'].includes(response.data?.mode), `unexpected policy ${response.data?.mode}`)
return response.data
})
const privateAddresses = await step('load customer-owned private destination addresses', async () => {
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/addresses?addressType=PRIVATE_DESTINATION')
requireSuccess(response)
const addresses = arrayData(response.data)
assert(addresses.length > 0, 'at least one customer-owned private destination address is required')
return addresses
})
const domestic = await loadWarehouses('DOMESTIC')
const fba = await loadWarehouses('FBA')
const overseas = await loadWarehouses('OVERSEAS')
assert(domestic.length > 0, 'at least one ACTIVE domestic warehouse is required')
assert(fba.length > 0, 'at least one ACTIVE FBA warehouse is required')
assert(overseas.length > 0, 'at least one ACTIVE overseas warehouse is required')
let receiving
let channel
for (const warehouse of domestic) {
const response = await jsonRequest(`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${warehouse.id}`)
requireSuccess(response)
const candidate = arrayData(response.data).find((item) => arrayData(item.pricingTaxModes).length > 0)
if (candidate) { receiving = warehouse; channel = candidate; break }
}
assert(receiving && channel, 'no domestic warehouse has an active pricing channel')
const privateAddress = privateAddresses[0]
const privateDestination = {
destinationType: 'PRIVATE', destinationCountryCode: privateAddress.countryCode,
destinationWarehouseName: privateAddress.addressName, privateDestinationAddressId: String(privateAddress.id),
}
const rows = [
{ clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, {
destinationType: 'FBA', destinationCountryCode: fba[0].countryCode, destinationWarehouseId: fba[0].id,
destinationWarehouseName: fba[0].warehouseName, amazonWarehouseCode: fba[0].warehouseCode,
}, 'FBA') },
{ clientRowId: `quick-order-overseas-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, {
destinationType: 'OVERSEAS', destinationCountryCode: overseas[0].countryCode,
destinationWarehouseId: overseas[0].id, destinationWarehouseName: overseas[0].warehouseName,
}, 'OVERSEAS') },
{ clientRowId: `quick-order-private-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
privateDestination, 'PRIVATE') },
{ clientRowId: `quick-order-invalid-channel-${marker}`, draft: {
...quickDraft(profile.tenantName, policy, receiving, channel, {
destinationType: 'FBA', destinationCountryCode: fba[0].countryCode, destinationWarehouseId: fba[0].id,
destinationWarehouseName: fba[0].warehouseName, amazonWarehouseCode: fba[0].warehouseCode,
}, 'INVALID'), channelCode: `INVALID-${marker}`, channelName: '不存在的渠道',
} },
]
const batchKey = `quick-order-http-${randomUUID()}`
report.resources.batchKey = batchKey
const created = await step('create FBA, overseas and private quick-order drafts with one invalid row', async () => {
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey },
})
requireSuccess(response)
assert(response.data?.total === 4, `expected total=4, got ${response.data?.total}`)
const rowEvidence = arrayData(response.data?.items).map((item) => ({
clientRowId: item.clientRowId, success: item.success, orderId: item.orderId, orderNo: item.orderNo,
errorCode: item.errorCode, errorMessage: item.errorMessage,
}))
assert(response.data?.succeeded === 3,
`expected succeeded=3, got ${response.data?.succeeded}; rows=${JSON.stringify(rowEvidence)}`)
assert(response.data?.failed === 1,
`expected failed=1, got ${response.data?.failed}; rows=${JSON.stringify(rowEvidence)}`)
const failed = response.data.items.find((item) => item.clientRowId === rows[3].clientRowId)
assert(failed && !failed.success && failed.errorCode, 'invalid channel row must fail independently')
return { ...response.data, items: rowEvidence }
})
const successfulItems = created.items.filter((item) => item.success)
for (const item of successfulItems) {
await step(`verify created draft ${item.clientRowId}`, async () => {
assert(item.orderId != null, 'successful row must include orderId')
assert(item.orderNo, 'successful row must include orderNo')
const response = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`)
requireSuccess(response)
assert(response.data?.order?.status === 'DRAFT', `expected DRAFT, got ${response.data?.order?.status}`)
assert(response.data?.order?.customerName === profile.tenantName, 'draft must belong to the authenticated enterprise')
report.resources.orderNos.push(item.orderNo)
return { orderNo: item.orderNo, status: response.data.order.status, customerName: response.data.order.customerName }
})
}
await step('same idempotency key replays the same quick-order drafts', async () => {
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey },
})
requireSuccess(response)
const originalIds = successfulItems.map((item) => String(item.orderId))
const replayIds = response.data.items.filter((item) => item.success).map((item) => String(item.orderId))
assert(JSON.stringify(replayIds) === JSON.stringify(originalIds), 'idempotent replay returned different order IDs')
return { replayedOrderNos: response.data.items.filter((item) => item.success).map((item) => item.orderNo) }
})
report.success = report.steps.every((item) => item.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')
console.log(JSON.stringify({ success: report.success, reportPath, orderNos: report.resources.orderNos, fatal: report.fatal }))
}
process.exitCode = report.success ? 0 : 1
function quickDraft(customerName, policy, receiving, channel, destination, suffix) {
const inboundCode = policy.mode === 'LEGACY_MANUAL' ? `QO${compactTimestamp()}${suffix.slice(0, 3)}` : undefined
return {
customerName,
transportMode: 'SEA', tradeTerm: 'FOB', expectedShipDate: undefined, customerRemark: `快速下单 HTTP 验收 ${marker} ${suffix}`,
pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: receiving.id, receivingWarehouseName: receiving.warehouseName,
channelCode: channel.channelCode, channelName: channel.channelName,
pricingTaxMode: channel.pricingTaxModes[0], taxMode: 'BUY_DECLARATION', containerMode: 'FULL_CONTAINER',
customsInspection: null, inboundCode, ...destination,
cargoes: [{
cargoName: '', packageType: 'CARTON', packageCount: 2, grossWeightKg: 0, volumeCbm: 0,
currency: 'USD', dangerousGoods: false, woodenProduct: false, skuNo: `SKU-${suffix}-${marker}`,
totalQuantity: 2, unitsPerPackage: 1,
}],
}
}
async function loadWarehouses(type) {
const all = []
for (let page = 1; ; page += 1) {
const response = await jsonRequest(`/api/merchant/v1/warehouses?page=${page}&size=200&status=ACTIVE&warehouseType=${type}`)
requireSuccess(response)
all.push(...pageRecords(response.data))
if (page >= Number(response.data?.pages ?? 1)) return all
}
}
async function step(name, action) {
const startedAt = new Date().toISOString()
try {
const evidence = await action()
report.steps.push({ name, passed: true, startedAt, completedAt: new Date().toISOString(), evidence })
return evidence
} catch (error) {
report.steps.push({ name, passed: false, startedAt, completedAt: new Date().toISOString(), error: sanitizeError(error) })
throw error
}
}
async function rawRequest(path, { method = 'GET', body, headers = {}, authenticated = true } = {}) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const requestHeaders = { ...headers }
if (authenticated && token) requestHeaders.Authorization = `Bearer ${token}`
let requestBody = body
if (body != null && !(body instanceof FormData)) {
requestHeaders['Content-Type'] = 'application/json'
requestBody = JSON.stringify(body)
}
try {
return await fetch(`${gateway}${path}`, { method, headers: requestHeaders, body: requestBody, signal: controller.signal })
} finally {
clearTimeout(timer)
}
}
async function jsonRequest(path, options) {
const response = await rawRequest(path, options)
const text = await response.text()
let json
try { json = text ? JSON.parse(text) : {} }
catch { throw new Error(`${path} returned non-JSON HTTP ${response.status}`) }
if (!response.ok) throw new Error(`${path} HTTP ${response.status} code=${json.code ?? '—'} requestId=${json.requestId ?? '—'} traceId=${json.traceId ?? '—'}`)
return json
}
function requireSuccess(response) {
if (String(response?.code) !== '200') {
throw new Error(`business failure code=${response?.code ?? '—'} message=${response?.message ?? '—'} requestId=${response?.requestId ?? '—'} traceId=${response?.traceId ?? '—'}`)
}
}
function parseArgs(values) {
const result = {}
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (value === '--help') { result.help = true; continue }
if (!value.startsWith('--')) throw new Error(`Unknown argument: ${value}`)
const key = value.slice(2)
const next = values[++index]
if (!next || next.startsWith('--')) throw new Error(`--${key} requires a value`)
result[key] = next
}
return result
}
function trimSlash(value) { return String(value ?? '').replace(/\/+$/, '') }
function compactTimestamp() { return new Date().toISOString().replace(/\D/g, '').slice(0, 14) }
function pageRecords(value) { return value?.records ?? value?.content ?? value?.items ?? [] }
function arrayData(value) { return Array.isArray(value) ? value : [] }
function assert(condition, message) { if (!condition) throw new Error(message) }
function sanitizeError(error) { return { name: error?.name ?? 'Error', message: String(error?.message ?? error) } }