OANEXT2 增加测试业务闭环验证

这个提交包含在:
2026-05-23 22:25:51 +08:00
父节点 de5f3253d1
当前提交 dc10a0d811
共修改 3 个文件,包含 528 行新增和 2 行删除
@@ -0,0 +1,526 @@
#!/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);
});