|
|
|
@@ -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);
|
|
|
|
|
});
|