#!/usr/bin/env node import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, ""); const reportDir = process.env.OA_TEST_SMOKE_BUSINESS_REPORT_DIR || path.resolve(`artifacts/qa-flow/oa-test-smoke-business-loop-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`); const writeEnabled = process.env.OA_TEST_SMOKE_BUSINESS_WRITE === "1"; const issueId = process.env.QA_ISSUE_ID?.trim() || "OANEXT2-5"; const marker = `${issueId}-TEST-SMOKE-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; const loginRetryWaitMs = Math.max(0, Number.parseInt(process.env.OA_TEST_SMOKE_LOGIN_RETRY_WAIT_MS || "65000", 10) || 0); const ROLE_ENV = { admin: ["LIVE_SMOKE_ADMIN_IDENTIFIER", "LIVE_SMOKE_ADMIN_PASSWORD"], employee: ["LIVE_SMOKE_EMPLOYEE_IDENTIFIER", "LIVE_SMOKE_EMPLOYEE_PASSWORD"], manager: ["LIVE_SMOKE_MANAGER_IDENTIFIER", "LIVE_SMOKE_MANAGER_PASSWORD"], finance: ["LIVE_SMOKE_FINANCE_IDENTIFIER", "LIVE_SMOKE_FINANCE_PASSWORD"], financeMgr: ["LIVE_SMOKE_FINANCE_MGR_IDENTIFIER", "LIVE_SMOKE_FINANCE_MGR_PASSWORD"], }; const summary = { issueId, mode: "oa-test-smoke-business-loop", status: "RUNNING", baseUrl, markerAlias: "TEST/SMOKE/OANEXT2", checkedAt: new Date().toISOString(), writeEnabled, privacy: "redacted status/count/alias report only; no password, token, Secret, full account, invoice value, contract body, bank statement, attachment content, full local path, or raw payload", boundaries: { testDataOnly: true, noRealPayment: true, noPaymentProvider: true, noErpWrite: true, noBankStatementImport: true, noSensitiveContractBody: true, syntheticReceiptOnly: true, }, preflight: {}, stages: {}, findings: [], }; function writeSummary() { mkdirSync(reportDir, { recursive: true }); writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf-8"); const lines = [ "# OA TEST/SMOKE 业务闭环脱敏报告", "", `结论:${summary.status}`, `写入开关:${writeEnabled ? "ON" : "OFF"}`, "", "## 阶段", "", ...Object.entries(summary.stages).map(([key, value]) => `- ${key}: ${JSON.stringify(value)}`), "", "## 边界", "", "- 仅创建 TEST/SMOKE/OANEXT2 标记数据。", "- 不执行真实银行付款、不接支付服务、不写 ERP、不导入银行流水。", "- 报告不输出密码、token、Secret、完整账号、真实票面值、合同正文、附件内容或原始 payload。", ]; writeFileSync(path.join(reportDir, "summary.md"), `${lines.join("\n")}\n`, "utf-8"); } function sanitizeError(value) { return String(value ?? "") .replace(/Bearer\s+[A-Za-z0-9._-]+/g, "Bearer ***") .replace(/accessToken["':\s]+[A-Za-z0-9._-]+/gi, "accessToken=***") .replace(/password[^,}\n]*/gi, "password=***") .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "***") .slice(0, 500); } function shortAlias(value, prefix) { const text = String(value || ""); return text ? `${prefix}-${text.slice(0, 6)}` : `${prefix}-missing`; } function today(offsetDays = 0) { const date = new Date(); date.setUTCDate(date.getUTCDate() + offsetDays); return date.toISOString().slice(0, 10); } function assertWriteAllowed() { if (!writeEnabled) { summary.status = "PAUSED_MANUAL_CONFIRMATION"; summary.findings.push({ code: "WRITE_SWITCH_OFF", message: "Set OA_TEST_SMOKE_BUSINESS_WRITE=1 to create TEST/SMOKE OA business data.", }); writeSummary(); console.log(`[oa-test-smoke-business] status=${summary.status} report=${reportDir}`); process.exit(0); } } async function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function fetchJson(pathName, init = {}) { const response = await fetch(`${baseUrl}${pathName}`, { ...init, signal: AbortSignal.timeout(30_000), }); const text = await response.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } return { response, json, text }; } async function requestJson(client, method, pathName, body, label, expected = [200, 201]) { const init = { method, headers: { accept: "application/json", authorization: `Bearer ${client.accessToken}`, ...(body === undefined ? {} : { "content-type": "application/json" }), }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }; const { response, json, text } = await fetchJson(pathName, init); if (!expected.includes(response.status)) { throw new Error(`${label} HTTP ${response.status}: ${sanitizeError(json?.error?.code || json?.error?.message || text)}`); } return json?.data; } async function getJson(client, pathName, label, expected) { return requestJson(client, "GET", pathName, undefined, label, expected); } async function postJson(client, pathName, body, label, expected) { return requestJson(client, "POST", pathName, body, label, expected); } async function patchJson(client, pathName, body, label, expected) { return requestJson(client, "PATCH", pathName, body, label, expected); } async function login(role, attempt = 0) { const [identifierEnv, passwordEnv] = ROLE_ENV[role] || []; const identifier = process.env[identifierEnv]?.trim(); const password = process.env[passwordEnv]?.trim(); if (!identifier || !password) { throw new Error(`missing_credentials:${role}`); } const { response, json, text } = await fetchJson("/api/auth/login", { method: "POST", headers: { "content-type": "application/json", "x-device-fingerprint": `oa-test-smoke-${role}`, }, body: JSON.stringify({ identifier, password }), }); if (response.status === 429 && attempt === 0 && loginRetryWaitMs > 0) { await wait(loginRetryWaitMs); return login(role, attempt + 1); } const accessToken = json?.data?.accessToken; if (!response.ok || typeof accessToken !== "string" || !accessToken) { throw new Error(`login_failed:${role}:HTTP_${response.status}:${sanitizeError(json?.error?.code || json?.error?.message || text)}`); } const me = await getJson({ accessToken }, "/api/me", `me:${role}`); return { role, accessToken, me, }; } async function loadFlag(admin, flagKey) { const data = await getJson(admin, "/api/system/feature-flags", "feature flags"); const item = data.items.find((candidate) => candidate.flagKey === flagKey); if (!item) throw new Error(`missing_feature_flag:${flagKey}`); return { flagKey, overrideMode: item.overrideMode, effectiveEnabled: item.effectiveEnabled, effectiveReason: item.effectiveReason, }; } async function preflight(admin) { const flags = { paymentBatch: await loadFlag(admin, "payment_batch_v1"), erpSync: await loadFlag(admin, "erp_sync"), projectLinking: await loadFlag(admin, "project_linking_v1"), }; summary.preflight.flags = flags; if (!flags.paymentBatch.effectiveEnabled) { throw Object.assign(new Error("payment_batch_v1_not_enabled"), { skip: true }); } if (flags.erpSync.effectiveEnabled) { throw Object.assign(new Error("erp_sync_must_stay_off"), { skip: true }); } if (!flags.projectLinking.effectiveEnabled) { throw Object.assign(new Error("project_linking_v1_not_enabled"), { skip: true }); } } async function firstItem(data, label) { const item = data?.items?.[0]; if (!item?.id) throw new Error(`missing_master_data:${label}`); return item; } async function waitForTask(client, entityType, entityId, label) { for (let attempt = 0; attempt < 20; attempt += 1) { const data = await getJson(client, `/api/workflow/tasks?status=pending&entityType=${encodeURIComponent(entityType)}&page=1&pageSize=50`, `workflow tasks:${label}`); const item = (data.items || []).find((task) => task.workflowInstance?.entityId === entityId || task.workflow_instance?.entity_id === entityId ); if (item?.id) return item.id; await wait(1000); } throw new Error(`workflow_task_not_found:${label}`); } async function approveIfTaskVisible(client, entityType, entityId, label) { try { const taskId = await waitForTask(client, entityType, entityId, label); await postJson(client, `/api/workflow/tasks/${taskId}/approve`, {}, `approve:${label}`); return true; } catch (error) { if (/workflow_task_not_found/.test(String(error?.message || error))) { return false; } throw error; } } async function createProject(manager, employee) { const data = await postJson(manager, "/api/projects", { projectNo: `TSO-${Date.now().toString().slice(-10)}`, name: `${marker} 项目联动测试`, clientName: "TEST/SMOKE 客户", ownerEmployeeId: employee.me.id, managerEmployeeId: manager.me.id, status: "active", startsAt: today(), targetEndAt: today(30), summary: "TEST/SMOKE/OANEXT2 技术闭环项目;不含真实客户资料。", isConfidential: false, }, "create project"); const project = data.item || data.project || data; summary.stages.project = { alias: shortAlias(project.id, "project"), status: project.status, hasProjectNo: Boolean(project.projectNo || project.project_no), }; return project; } async function createContract(manager, project) { const data = await postJson(manager, "/api/contracts-registry", { contractNo: `TSO-CT-${Date.now().toString().slice(-10)}`, projectId: project.id, contractType: "service", counterpartyName: "TEST/SMOKE 交易对手", summary: "TEST/SMOKE/OANEXT2 合同台账测试;合同正文线下归档,不上传公网。", amount: "100.00", currency: "CNY", signedDate: today(), effectiveDate: today(), expiryDate: today(365), confidentialityLevel: "internal", archiveLocation: `OFFLINE-TEST-SMOKE-OANEXT2-${Date.now().toString().slice(-8)}`, offlineArchiveNo: `TSO-ARCH-${Date.now().toString().slice(-8)}`, ownerEmployeeId: manager.me.id, status: "active", }, "create contract registry"); const item = data.item || data; summary.stages.contract = { alias: shortAlias(item.id, "contract"), linkedProject: Boolean(project.id), hasOfflineArchive: Boolean(item.archiveLocation || item.archive_location), }; return item; } async function createAndApproveReimbursement(clients) { const reimbursement = await postJson(clients.employee, "/api/reimbursements", { description: `${marker} 报销付款闭环测试`, reimbursementDate: today(), }, "create reimbursement"); const expenseTypes = await getJson(clients.employee, "/api/expense-types?enabled=true", "expense types"); const expenseType = await firstItem(expenseTypes, "expense_type"); await postJson(clients.employee, `/api/reimbursements/${reimbursement.id}/items`, { expenseTypeId: expenseType.id, expenseDate: today(), amount: "12.34", description: `${marker} 小额报销测试项`, }, "create reimbursement item"); const invoice = await bindAvailableInvoice(clients.employee, reimbursement.id); const submitted = await postJson(clients.employee, `/api/reimbursements/${reimbursement.id}/submit`, {}, "submit reimbursement"); await postJson(clients.manager, `/api/workflow/tasks/${submitted.currentTaskId}/approve`, {}, "manager approve reimbursement"); await approveIfTaskVisible(clients.finance, "reimbursement", reimbursement.id, "finance reimbursement"); const approved = await getJson(clients.finance, `/api/reimbursements/${reimbursement.id}`, "approved reimbursement"); if (approved.status !== "approved") { throw new Error(`reimbursement_not_approved:${approved.status}`); } summary.stages.reimbursement = { alias: shortAlias(reimbursement.id, "reimbursement"), invoiceAlias: shortAlias(invoice.id, "invoice"), invoiceSource: invoice.source, submitStatus: "submitted", finalStatusBeforePayment: approved.status, usedOcrEvidenceRef: "OINV1-4 small-sample OCR evidence; this loop binds an existing available invoice or a synthetic general PDF, never a new real raw invoice image", }; return approved; } async function bindAvailableInvoice(employee, reimbursementId) { const pool = await getJson(employee, "/api/invoice-pool?ownerScope=own&pageSize=50", "invoice pool"); let invoice = (pool.items || []).find((item) => item.status === "available" && item.id); let source = "existing_available_invoice_pool_item"; if (!invoice) { invoice = await uploadSyntheticGeneralInvoice(employee); source = "synthetic_general_pdf_invoice_pool_item"; } await postJson(employee, `/api/invoice-pool/${invoice.id}/bind`, { reimbursementId }, "bind available invoice"); return { id: invoice.id, source }; } async function uploadSyntheticGeneralInvoice(employee) { const form = new FormData(); form.append("ocr_type", "general"); form.append( "file", new Blob([Buffer.from(`%PDF-1.4\n${issueId} TEST SMOKE synthetic invoice ${marker}\n%%EOF\n`, "utf8")], { type: "application/pdf" }), `test-smoke-invoice-${Date.now()}.pdf`, ); const response = await fetch(`${baseUrl}/api/invoice-pool/upload`, { method: "POST", headers: { accept: "application/json", authorization: `Bearer ${employee.accessToken}`, }, body: form, signal: AbortSignal.timeout(30_000), }); const text = await response.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch { payload = null; } const data = payload?.data; if (!response.ok || !data?.invoicePoolId || data.status !== "available") { throw new Error(`synthetic_invoice_upload_failed:${response.status}:${sanitizeError(data?.unavailableReason || payload?.error?.code || payload?.error?.message || text)}`); } return { id: data.invoicePoolId }; } async function createPaymentBatchAndExecute(clients, reimbursement) { const batch = (await postJson(clients.finance, "/api/payment-batches", { entityType: "reimbursement", name: `${marker} 付款批次测试`, entityIds: [reimbursement.id], defaultPaymentDate: today(), defaultPaymentChannel: "bank", voucherPrefix: `TSO${Date.now().toString().slice(-6)}`, }, "create payment batch")).item; await postJson(clients.financeMgr, `/api/payment-batches/${batch.id}/review`, { comment: `${issueId} TEST/SMOKE 复核`, }, "review payment batch"); const denied = await fetchJson(`/api/payment-batches/${batch.id}/execute`, { method: "POST", headers: { accept: "application/json", authorization: `Bearer ${clients.finance.accessToken}`, "content-type": "application/json", }, body: "{}", }); if (denied.response.status !== 409) { throw new Error(`execute_without_receipt_expected_409:${denied.response.status}`); } const form = new FormData(); form.append("file", new Blob([Buffer.from(`%PDF-1.4\n${issueId} TEST SMOKE synthetic receipt ${marker}\n%%EOF\n`, "utf8")], { type: "application/pdf" }), `test-smoke-receipt-${Date.now()}.pdf`); const receiptResponse = await fetch(`${baseUrl}/api/payment-batches/${batch.id}/receipt-attachments`, { method: "POST", headers: { accept: "application/json", authorization: `Bearer ${clients.finance.accessToken}`, }, body: form, signal: AbortSignal.timeout(30_000), }); if (!receiptResponse.ok) { throw new Error(`receipt_upload_failed:${receiptResponse.status}:${sanitizeError(await receiptResponse.text())}`); } const executed = (await postJson(clients.finance, `/api/payment-batches/${batch.id}/execute`, {}, "execute payment batch")).item; const paid = await getJson(clients.finance, `/api/reimbursements/${reimbursement.id}`, "paid reimbursement"); const ledger = await getJson(clients.finance, `/api/ap-operations/reimbursement-ledger?page=1&pageSize=20&q=${encodeURIComponent(paid.formNumber || reimbursement.id)}`, "finance ledger"); const exportResponse = await fetch(`${baseUrl}/api/exports/ap-operations`, { method: "POST", headers: { accept: "text/csv,application/json", authorization: `Bearer ${clients.finance.accessToken}`, "content-type": "application/json", }, body: JSON.stringify({ format: "csv", ledgerSearch: paid.formNumber || reimbursement.id }), signal: AbortSignal.timeout(30_000), }); if (![200, 409].includes(exportResponse.status)) { throw new Error(`ap_export_unexpected_status:${exportResponse.status}`); } summary.stages.payment = { alias: shortAlias(batch.id, "paymentBatch"), batchStatus: executed.status, missingReceiptGuard: "PASS_409", reimbursementStatus: paid.status, ledgerHit: (ledger.items || []).some((item) => item.id === reimbursement.id || item.formNumber === paid.formNumber), apExportStatus: exportResponse.status, }; } async function createAndApprovePurchase(clients, project, contract) { const legalEntity = await firstItem(await getJson(clients.employee, "/api/master-data/legal-entities?enabled=true", "legal entities"), "legal_entity"); const costCenter = await firstItem(await getJson(clients.employee, `/api/master-data/cost-centers?enabled=true&legalEntityId=${legalEntity.id}`, "cost centers"), "cost_center"); const supplier = await firstItem(await getJson(clients.employee, `/api/master-data/suppliers?enabled=true&legalEntityId=${legalEntity.id}`, "suppliers"), "supplier"); const documentCategory = await firstItem(await getJson(clients.employee, "/api/master-data/document-categories?enabled=true&entityType=purchase_request", "document categories"), "document_category"); const purchase = (await postJson(clients.employee, "/api/purchase-requests", {}, "create purchase request")).purchaseRequest; await patchJson(clients.employee, `/api/purchase-requests/${purchase.id}`, { title: `${marker} 采购费用测试`, reason: "TEST/SMOKE/OANEXT2 采购与项目/合同关联测试", legalEntityId: legalEntity.id, costCenterId: costCenter.id, supplierId: supplier.id, documentCategoryId: documentCategory.id, projectId: project.id, expectedDeliveryDate: today(15), items: [ { description: `${marker} 低风险采购测试项`, specification: "TEST/SMOKE", quantity: "1", unitPrice: "88.00", isAssetCandidate: false, }, ], }, "patch purchase request"); const submitted = await postJson(clients.employee, `/api/purchase-requests/${purchase.id}/submit`, {}, "submit purchase request"); await postJson(clients.manager, `/api/workflow/tasks/${submitted.currentTaskId}/approve`, {}, "manager approve purchase"); await approveIfTaskVisible(clients.financeMgr, "purchase_request", purchase.id, "financeMgr purchase"); const approved = await getJson(clients.manager, `/api/purchase-requests/${purchase.id}`, "approved purchase request"); if (approved.purchaseRequest?.status !== "approved" && approved.status !== "approved") { throw new Error(`purchase_not_approved:${approved.purchaseRequest?.status || approved.status}`); } await postJson(clients.financeMgr, `/api/purchase-requests/${purchase.id}/archive-order`, { poNo: `TSO-PO-${Date.now().toString().slice(-10)}`, contractNo: contract.contractNo || contract.contract_no || `TSO-CT-${Date.now().toString().slice(-10)}`, archivedFiles: [], }, "archive purchase order"); const projectPurchaseSummary = await getJson(clients.manager, `/api/projects/${project.id}/purchase-requests/summary`, "project purchase summary"); const contracts = await getJson(clients.manager, `/api/contracts-registry?projectId=${project.id}`, "contract registry by project"); summary.stages.procurement = { alias: shortAlias(purchase.id, "purchase"), finalStatus: "approved", linkedProject: Boolean(project.id), archivedOrder: true, contractRegistryVisible: (contracts.items || []).length > 0, projectPurchaseCount: Array.isArray(projectPurchaseSummary.items) ? projectPurchaseSummary.items.length : null, }; } async function main() { mkdirSync(reportDir, { recursive: true }); assertWriteAllowed(); try { const clients = { admin: await login("admin"), employee: await login("employee"), manager: await login("manager"), finance: await login("finance"), financeMgr: await login("financeMgr"), }; summary.preflight.roles = Object.fromEntries( Object.entries(clients).map(([role, client]) => [role, { login: "PASS", roleCodes: (client.me.roleCodes || []).filter((item) => typeof item === "string").sort(), }]), ); await preflight(clients.admin); const project = await createProject(clients.manager, clients.employee); const contract = await createContract(clients.manager, project); const reimbursement = await createAndApproveReimbursement(clients); await createPaymentBatchAndExecute(clients, reimbursement); await createAndApprovePurchase(clients, project, contract); summary.status = "PASS_TEST_SMOKE_BUSINESS_LOOP_READY"; } catch (error) { const status = error?.skip ? "SKIP/BLOCKED_BY_EXTERNAL_CONFIG" : "FAIL"; summary.status = status; summary.findings.push({ code: status, message: sanitizeError(error instanceof Error ? error.message : String(error)), }); if (status === "FAIL") { process.exitCode = 1; } } finally { summary.completedAt = new Date().toISOString(); writeSummary(); console.log(`[oa-test-smoke-business] status=${summary.status} report=${reportDir}`); } } main().catch((error) => { summary.status = "FAIL"; summary.findings.push({ code: "UNHANDLED", message: sanitizeError(error instanceof Error ? error.message : String(error)) }); summary.completedAt = new Date().toISOString(); writeSummary(); console.error(`[oa-test-smoke-business][ERROR] ${sanitizeError(error instanceof Error ? error.message : String(error))}`); process.exit(1); });