import { readFile } from 'node:fs/promises' import { basename } from 'node:path' import process from 'node:process' const MODULE = 'inbound-weight-ocr' const SCENARIO = 'weight-image-recognition-and-confirmation' const DEFAULT_IMAGE = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const EDITABLE_STATUSES = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED']) const OCR_STATUSES = new Set(['RECOGNIZED', 'FAILED']) const NEW_CONTRACT_OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const SCENARIO_OPTIONS = { scenario: SCENARIO } function id(value) { return value == null ? '' : String(value) } function apiOk(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function closeNumber(actual, expected, tolerance = 0.0005) { return Number.isFinite(Number(actual)) && Math.abs(Number(actual) - Number(expected)) < tolerance } function expectedConflict(response) { return response?.http === 200 && ['A0409', '409'].includes(String(response?.body?.code)) } function expectedRejection(response) { return response?.http === 200 && response?.body && String(response.body.code) !== '200' } function markerVisible(detail, marker) { const values = [ detail?.publicRemark, detail?.summary?.inboundCode, detail?.summary?.orderNo, detail?.summary?.customerName, detail?.summary?.companyName, ...(Array.isArray(detail?.internalNotes) ? detail.internalNotes.map((note) => note?.content) : []), ] return values.some((value) => typeof value === 'string' && value.includes(marker)) } function candidateValues(context) { const shared = context.context || {} return [ shared.inboundWeightOcr, shared.ocrInbound, shared.testInbound, shared.createdInbound, shared.inboundId && shared.tenantId ? { inboundId: shared.inboundId, tenantId: shared.tenantId, marker: shared.inboundMarker } : null, ].filter(Boolean) } async function resolveMarkedInbound(context) { for (const candidate of candidateValues(context)) { const tenantId = id(candidate.tenantId || candidate.customerId) const inboundId = id(candidate.inboundId || candidate.id) if (!tenantId || !inboundId) continue const response = await context.request( 'GET', `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`, ) const detail = apiOk(response) ? response.body.data : null const marked = markerVisible(detail, context.marker) const editable = EDITABLE_STATUSES.has(detail?.summary?.processStatus) if (detail && marked && editable) return { tenantId, inboundId, detail, response } } const filters = [ `inboundCode=${encodeURIComponent(context.marker)}`, `orderNo=${encodeURIComponent(context.marker)}`, ] for (const filter of filters) { const pageResponse = await context.request('GET', `/api/platform/inbounds?page=1&size=20&${filter}`) if (!apiOk(pageResponse)) continue for (const row of pageResponse.body.data?.records || []) { const tenantId = id(row.tenantId) const inboundId = id(row.id) if (!tenantId || !inboundId || !EDITABLE_STATUSES.has(row.processStatus)) continue const detailResponse = await context.request( 'GET', `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`, ) const detail = apiOk(detailResponse) ? detailResponse.body.data : null if (detail && markerVisible(detail, context.marker)) { return { tenantId, inboundId, detail, response: detailResponse } } } } return null } async function multipart(context, path, file, token = context.token) { const form = new FormData() form.append('file', new Blob([file.bytes], { type: file.contentType }), file.name) const headers = { Accept: 'application/json' } if (token) headers.Authorization = `Bearer ${token}` try { const response = await fetch(`${context.options.gateway}${path}`, { method: 'POST', headers, body: form, signal: AbortSignal.timeout(120_000), }) const content = await response.text() let payload = null try { payload = content ? JSON.parse(content) : null } catch { /* Keep bounded diagnostic text only. */ } return { method: 'POST', path, http: response.status, body: payload, raw: payload === null ? content.slice(0, 500) : null, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id'), } } catch (error) { return { method: 'POST', path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null, } } } function jpegDimensions(bytes) { if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null const standalone = new Set([0x01, 0xd8, 0xd9, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7]) const startsOfFrame = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]) let offset = 2 while (offset + 3 < bytes.length) { while (offset < bytes.length && bytes[offset] !== 0xff) offset += 1 while (offset < bytes.length && bytes[offset] === 0xff) offset += 1 if (offset >= bytes.length) break const marker = bytes[offset] offset += 1 if (standalone.has(marker)) continue if (offset + 1 >= bytes.length) break const length = bytes[offset] * 256 + bytes[offset + 1] if (length < 2 || offset + length > bytes.length) break if (startsOfFrame.has(marker) && length >= 7) { return { height: bytes[offset + 3] * 256 + bytes[offset + 4], width: bytes[offset + 5] * 256 + bytes[offset + 6], } } offset += length } return null } function workspacePath(tenantId, inboundId) { return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/measurement?tenantId=${encodeURIComponent(tenantId)}` } function resultPath(inboundId, resultId) { return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/weight-results/${encodeURIComponent(resultId)}` } function confirmPath(tenantId, inboundId, resultId) { return `${resultPath(inboundId, resultId)}/confirm?tenantId=${encodeURIComponent(tenantId)}` } function deletePath(tenantId, inboundId, resultId, version) { return `${resultPath(inboundId, resultId)}?tenantId=${encodeURIComponent(tenantId)}&version=${version}` } async function createUploadedResult(context, target, image, overrides = {}) { const imagePath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}` const uploadResponse = await multipart(context, imagePath, image) const upload = context.apiSuccess(MODULE, '上传称重图片并执行 OCR', uploadResponse, `file=${image.name}, size=${image.bytes.length}`, SCENARIO_OPTIONS) const fileId = id(upload?.fileId) context.assert(MODULE, '上传响应保持文件 ID 字符串契约', uploadResponse, Boolean(fileId) && typeof upload?.fileId === 'string', `fileIdType=${typeof upload?.fileId}, status=${upload?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS) context.assert(MODULE, 'OCR 上传响应字段完整且置信度受限', uploadResponse, upload && OCR_STATUSES.has(upload.recognitionStatus) && Object.hasOwn(upload, 'weightValue') && Object.hasOwn(upload, 'weightUnit') && Object.hasOwn(upload, 'confidence') && (upload.confidence == null || (Number(upload.confidence) >= 0 && Number(upload.confidence) <= 1)), `status=${upload?.recognitionStatus ?? 'missing'}, hasWeight=${upload?.weightValue != null}, confidence=${upload?.confidence ?? 'null'}`, SCENARIO_OPTIONS) if (!fileId) throw new Error('称重图片上传未返回 fileId,无法登记和清理测试资源') let recognized = upload if (overrides.retry) { const retryPath = `${imagePath}/${encodeURIComponent(fileId)}/recognize` const retryResponse = await context.request('POST', retryPath) const retry = context.apiSuccess(MODULE, '重试当前称重图片 OCR', retryResponse, `fileId=${fileId}`, SCENARIO_OPTIONS) context.assert(MODULE, 'OCR 重试保持文件身份并返回识别契约', retryResponse, id(retry?.fileId) === fileId && OCR_STATUSES.has(retry?.recognitionStatus) && Object.hasOwn(retry || {}, 'weightValue') && Object.hasOwn(retry || {}, 'confidence'), `sameFile=${id(retry?.fileId) === fileId}, status=${retry?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS) if (retry) recognized = retry } const payload = { fileId, ocrProvider: `ADMIN_ACCEPTANCE_${context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-24)}`, ocrRawText: overrides.manual ? null : (recognized.ocrRawText || null), ocrWeightValue: overrides.manual ? null : recognized.weightValue, ocrWeightUnit: overrides.manual ? null : recognized.weightUnit, confidence: overrides.manual ? null : recognized.confidence, } const createPath = `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(target.inboundId)}/weight-results?tenantId=${encodeURIComponent(target.tenantId)}` let createResponse = await context.request('POST', createPath, payload) let result = context.apiSuccess(MODULE, overrides.manual ? '登记人工录入重量结果' : '登记 OCR 原值', createResponse, `fileId=${fileId}`, SCENARIO_OPTIONS) if (!result && !overrides.manual) { createResponse = await context.request('POST', createPath, { ...payload, ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null, }) result = context.apiSuccess(MODULE, 'OCR 原值登记失败后建立可清理人工结果', createResponse, `fileId=${fileId}`, NEW_CONTRACT_OPTIONS) } const resultId = id(result?.id) context.assert(MODULE, '重量结果 ID、文件 ID 和初始版本契约正确', createResponse, Boolean(resultId) && typeof result?.id === 'string' && typeof result?.fileId === 'string' && id(result?.fileId) === fileId && Number(result?.version) === 0, `resultIdType=${typeof result?.id}, fileIdType=${typeof result?.fileId}, version=${result?.version ?? 'missing'}`, SCENARIO_OPTIONS) if (!resultId) throw new Error(`文件 ${fileId} 已上传但重量结果登记失败,需要人工清理该测试文件`) return { upload: recognized, fileId, result, resultId, version: Number(result.version), deleted: false } } async function verifyImages(context, target, item, image) { const base = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}/${encodeURIComponent(item.fileId)}` const original = await context.requestBinary('GET', base) context.assert(MODULE, '鉴权读取原始称重图片', original, original.http === 200 && original.bytes.length === image.bytes.length && /^image\/(jpeg|png|bmp)/i.test(original.contentType), `contentType=${original.contentType || 'missing'}, bytes=${original.bytes.length}`, SCENARIO_OPTIONS) const thumbnail = await context.requestBinary('GET', `${base}/thumbnail`) const dimensions = jpegDimensions(thumbnail.bytes) context.assert(MODULE, '缩略图返回受限尺寸 JPEG', thumbnail, thumbnail.http === 200 && /^image\/jpeg/i.test(thumbnail.contentType) && thumbnail.bytes.length > 0 && dimensions && dimensions.width <= 480 && dimensions.height <= 320, `contentType=${thumbnail.contentType || 'missing'}, bytes=${thumbnail.bytes.length}, dimensions=${dimensions ? `${dimensions.width}x${dimensions.height}` : 'unreadable'}`, NEW_CONTRACT_OPTIONS) } async function loadWorkspace(context, target, name) { const response = await context.request('GET', workspacePath(target.tenantId, target.inboundId)) const workspace = context.apiSuccess(MODULE, name, response, `inboundId=${target.inboundId}`, SCENARIO_OPTIONS) return { response, workspace } } async function rejectWithoutMutation(context, target, item, name, payload) { const path = confirmPath(target.tenantId, target.inboundId, item.resultId) const response = await context.request('POST', path, payload) context.assert(MODULE, name, response, expectedRejection(response), `HTTP=${response.http}, code=${response.body?.code ?? 'missing'}`, SCENARIO_OPTIONS) const check = await loadWorkspace(context, target, `${name}后回查工作区`) const persisted = check.workspace?.weightResults?.find((row) => id(row.id) === item.resultId) context.assert(MODULE, `${name}未改变重量结果`, check.response, persisted && Number(persisted.version) === item.version && persisted.recognitionStatus !== 'CONFIRMED', `version=${persisted?.version ?? 'missing'}, status=${persisted?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS) } async function deleteResult(context, target, item, name, options = NEW_CONTRACT_OPTIONS) { const path = deletePath(target.tenantId, target.inboundId, item.resultId, item.version) const response = await context.request('DELETE', path) const passed = apiOk(response) context.addStep(MODULE, name, response, passed, `resultId=${item.resultId}, version=${item.version}`, options) if (passed) item.deleted = true return { response, passed } } async function verifyDeleted(context, target, item, baselineWeight) { const check = await loadWorkspace(context, target, '删除后回查重量工作区') const missing = !check.workspace?.weightResults?.some((row) => id(row.id) === item.resultId) context.assert(MODULE, '删除后结果消失且总毛重恢复基线', check.response, missing && (baselineWeight == null ? check.workspace?.actualGrossWeightKg == null : closeNumber(check.workspace?.actualGrossWeightKg, baselineWeight)), `resultMissing=${missing}, baseline=${baselineWeight ?? 'null'}, actual=${check.workspace?.actualGrossWeightKg ?? 'null'}`, NEW_CONTRACT_OPTIONS) const imagePath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}/${encodeURIComponent(item.fileId)}` const image = await context.requestBinary('GET', imagePath) context.assert(MODULE, '删除结果后原始图片不可再读取', image, image.http === 200 && image.body && String(image.body.code) !== '200', `HTTP=${image.http}, code=${image.body?.code ?? 'missing'}`, NEW_CONTRACT_OPTIONS) } export async function run(context) { const scenarioStart = context.steps.length const created = [] const target = await resolveMarkedInbound(context) context.assert(MODULE, '仅选择带本轮测试标记的可编辑入库单', target?.response || null, Boolean(target), target ? `tenantId=${target.tenantId}, inboundId=${target.inboundId}, status=${target.detail.summary.processStatus}` : `marker=${context.marker}, 未找到本轮创建且状态为 ${[...EDITABLE_STATUSES].join('/')} 的入库单`, SCENARIO_OPTIONS) if (!target) { context.scenarios.push({ name: SCENARIO, module: MODULE, success: false, deploymentBlocked: false, passed: 0, failed: 1, }) return } context.context.inboundWeightOcr = { tenantId: target.tenantId, inboundId: target.inboundId, marker: context.marker, } context.createdResources.inboundWeightOcr = { tenantId: target.tenantId, inboundId: target.inboundId, resultIds: [], cleaned: false, } const imagePath = process.env.ADMIN_TEST_OCR_IMAGE || DEFAULT_IMAGE const imageBytes = await readFile(imagePath) const image = { bytes: imageBytes, name: basename(imagePath), contentType: 'image/jpeg' } const initial = await loadWorkspace(context, target, '读取 OCR 测试前重量工作区') if (!initial.workspace) throw new Error('无法读取测试入库单重量工作区') const baselineWeight = initial.workspace.actualGrossWeightKg const baselineIds = new Set((initial.workspace.weightResults || []).map((row) => id(row.id))) context.assert(MODULE, '测试入库单身份与基线可追踪', initial.response, id(initial.workspace.inboundId) === target.inboundId && initial.workspace.processStatus === target.detail.summary.processStatus, `existingResults=${baselineIds.size}, baselineWeight=${baselineWeight ?? 'null'}`, SCENARIO_OPTIONS) const uploadPath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}` const noAuth = await multipart(context, uploadPath, image, '') context.assert(MODULE, '未认证请求不能上传称重图片', noAuth, noAuth.http === 401 || noAuth.http === 403, `HTTP=${noAuth.http}, code=${noAuth.body?.code ?? 'none'}`, SCENARIO_OPTIONS) const wrongType = await multipart(context, uploadPath, { bytes: new TextEncoder().encode(context.marker), name: `${context.marker}.txt`, contentType: 'text/plain', }) context.assert(MODULE, '非图片类型被拒绝且不模拟成功', wrongType, expectedRejection(wrongType), `HTTP=${wrongType.http}, code=${wrongType.body?.code ?? 'missing'}`, SCENARIO_OPTIONS) const oversized = await multipart(context, uploadPath, { bytes: new Uint8Array(5 * 1024 * 1024 + 1), name: `${context.marker}.jpg`, contentType: 'image/jpeg', }) context.assert(MODULE, '超过5MB图片被拒绝', oversized, expectedRejection(oversized), `HTTP=${oversized.http}, code=${oversized.body?.code ?? 'missing'}`, SCENARIO_OPTIONS) try { const original = await createUploadedResult(context, target, image, { retry: true }) created.push(original) context.createdResources.inboundWeightOcr.resultIds.push(original.resultId) await verifyImages(context, target, original, image) const registered = await loadWorkspace(context, target, '登记后回查 OCR 重量结果') const registeredRow = registered.workspace?.weightResults?.find((row) => id(row.id) === original.resultId) context.assert(MODULE, '工作区保留本轮 OCR 原值且未覆盖历史结果', registered.response, registeredRow && !baselineIds.has(original.resultId) && closeNumber(registeredRow.ocrWeightValue, original.result.ocrWeightValue) && registeredRow.ocrWeightUnit === original.result.ocrWeightUnit, `resultFound=${Boolean(registeredRow)}, baselineCount=${baselineIds.size}, currentCount=${registered.workspace?.weightResults?.length ?? 'n/a'}`, SCENARIO_OPTIONS) const staleConfirm = await context.request('POST', confirmPath(target.tenantId, target.inboundId, original.resultId), { version: original.version + 1 }) context.assert(MODULE, '旧版本确认返回并发冲突', staleConfirm, expectedConflict(staleConfirm), `HTTP=${staleConfirm.http}, code=${staleConfirm.body?.code ?? 'missing'}`, NEW_CONTRACT_OPTIONS) const recognizedValue = Number(original.result.ocrWeightValue) const recognizedUnit = original.result.ocrWeightUnit const canConfirmOriginal = Number.isFinite(recognizedValue) && recognizedValue > 0 && ['KG', 'KGS', 'LB', 'LBS'].includes(String(recognizedUnit || '').trim().toUpperCase()) const confirmPayload = canConfirmOriginal ? { version: original.version } : { version: original.version, adjustedWeightValue: 1.234, adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} OCR未返回有效重量,人工验收`, } const confirmResponse = await context.request('POST', confirmPath(target.tenantId, target.inboundId, original.resultId), confirmPayload) const confirmed = context.apiSuccess(MODULE, '确认 OCR 重量并计入实际总毛重', confirmResponse, `usedOriginal=${canConfirmOriginal}`, NEW_CONTRACT_OPTIONS) context.assert(MODULE, '确认结果状态、版本和最终KG正确', confirmResponse, confirmed?.recognitionStatus === 'CONFIRMED' && Number(confirmed?.version) === original.version + 1 && Number(confirmed?.finalWeightKg) > 0, `status=${confirmed?.recognitionStatus ?? 'missing'}, version=${confirmed?.version ?? 'missing'}, finalKg=${confirmed?.finalWeightKg ?? 'missing'}`, NEW_CONTRACT_OPTIONS) original.version = Number(confirmed?.version ?? original.version) const afterConfirm = await loadWorkspace(context, target, '确认后回查实际总毛重') const expectedTotal = Number(baselineWeight || 0) + Number(confirmed?.finalWeightKg || 0) context.assert(MODULE, '确认后实际总毛重按服务端结果重算', afterConfirm.response, closeNumber(afterConfirm.workspace?.actualGrossWeightKg, expectedTotal), `baseline=${baselineWeight ?? 'null'}, added=${confirmed?.finalWeightKg ?? 'missing'}, actual=${afterConfirm.workspace?.actualGrossWeightKg ?? 'missing'}`, NEW_CONTRACT_OPTIONS) const staleDelete = await context.request('DELETE', deletePath(target.tenantId, target.inboundId, original.resultId, Math.max(0, original.version - 1))) context.assert(MODULE, '旧版本删除返回并发冲突', staleDelete, expectedConflict(staleDelete), `HTTP=${staleDelete.http}, code=${staleDelete.body?.code ?? 'missing'}`, NEW_CONTRACT_OPTIONS) const deletedOriginal = await deleteResult(context, target, original, '删除本轮已确认图片及重量结果') if (deletedOriginal.passed) await verifyDeleted(context, target, original, baselineWeight) const manual = await createUploadedResult(context, target, image, { manual: true }) created.push(manual) context.createdResources.inboundWeightOcr.resultIds.push(manual.resultId) await rejectWithoutMutation(context, target, manual, '零重量确认被拒绝', { version: manual.version, adjustedWeightValue: 0, adjustedWeightUnit: 'KG', adjustmentReason: context.marker, }) await rejectWithoutMutation(context, target, manual, '负重量确认被拒绝', { version: manual.version, adjustedWeightValue: -1, adjustedWeightUnit: 'KG', adjustmentReason: context.marker, }) await rejectWithoutMutation(context, target, manual, 'LB 0.001换算为零后被拒绝', { version: manual.version, adjustedWeightValue: 0.001, adjustedWeightUnit: 'LB', adjustmentReason: context.marker, }) await rejectWithoutMutation(context, target, manual, '超出15位整数重量被拒绝', { version: manual.version, adjustedWeightValue: 1_000_000_000_000_000, adjustedWeightUnit: 'KG', adjustmentReason: context.marker, }) const adjustedValue = 10.125 const adjustedConfirmResponse = await context.request('POST', confirmPath(target.tenantId, target.inboundId, manual.resultId), { version: manual.version, adjustedWeightValue: adjustedValue, adjustedWeightUnit: 'LB', adjustmentReason: `${context.marker} 人工确认 LB`, }) const adjusted = context.apiSuccess(MODULE, '人工改重与确认原子完成', adjustedConfirmResponse, `adjusted=${adjustedValue} LB`, NEW_CONTRACT_OPTIONS) const expectedKg = Math.round(adjustedValue * 0.45359237 * 1000) / 1000 context.assert(MODULE, 'LB 人工改重保留输入并正确换算最终KG', adjustedConfirmResponse, adjusted?.recognitionStatus === 'CONFIRMED' && Number(adjusted?.version) === manual.version + 2 && closeNumber(adjusted?.adjustedWeightValue, adjustedValue) && adjusted?.adjustedWeightUnit === 'LB' && closeNumber(adjusted?.finalWeightKg, expectedKg), `adjusted=${adjusted?.adjustedWeightValue ?? 'missing'} ${adjusted?.adjustedWeightUnit ?? ''}, finalKg=${adjusted?.finalWeightKg ?? 'missing'}, expectedKg=${expectedKg}`, NEW_CONTRACT_OPTIONS) manual.version = Number(adjusted?.version ?? manual.version) const deletedManual = await deleteResult(context, target, manual, '删除本轮人工改重图片及结果') if (deletedManual.passed) await verifyDeleted(context, target, manual, baselineWeight) } finally { for (const item of created.filter((value) => !value.deleted)) { await deleteResult(context, target, item, '异常收尾清理本轮重量结果', NEW_CONTRACT_OPTIONS) } const cleanup = await loadWorkspace(context, target, '最终清理后回查重量工作区') const createdIds = new Set(created.map((item) => item.resultId)) const leftovers = (cleanup.workspace?.weightResults || []).filter((row) => createdIds.has(id(row.id))) const restored = leftovers.length === 0 && (baselineWeight == null ? cleanup.workspace?.actualGrossWeightKg == null : closeNumber(cleanup.workspace?.actualGrossWeightKg, baselineWeight)) context.assert(MODULE, '仅清理本轮结果且订单总毛重恢复运行前基线', cleanup.response, restored, `leftovers=${leftovers.length}, baseline=${baselineWeight ?? 'null'}, actual=${cleanup.workspace?.actualGrossWeightKg ?? 'null'}`, NEW_CONTRACT_OPTIONS) context.createdResources.inboundWeightOcr.cleaned = restored } const steps = context.steps.slice(scenarioStart) context.scenarios.push({ name: SCENARIO, module: MODULE, success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker), passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length, }) }