R105/A2-A5 补强试运行自动化验收

这个提交包含在:
2026-05-07 12:52:18 +08:00
父节点 d99cbf8491
当前提交 86975dd2a4
共修改 9 个文件,包含 679 行新增和 4 行删除
+1
查看文件
@@ -24,6 +24,7 @@
"job:archive-unused-invoices": "node src/scripts/run-invoice-archive-once.js",
"job:integration-outbox-once": "node src/scripts/run-integration-outbox-once.js",
"job:verification-once": "node src/scripts/run-verification-once.js",
"audit:smoke-data": "node src/scripts/audit-smoke-data.js",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
+277
查看文件
@@ -0,0 +1,277 @@
require("dotenv/config");
const { createPrismaClient } = require("../prismaClient");
const DEFAULT_SMOKE_IDENTIFIERS = [
"SMOKE001",
"SMOKE002",
"[email protected]",
"[email protected]",
];
function printHelp() {
process.stdout.write(`Usage: node src/scripts/audit-smoke-data.js [options]
只读统计 smoke 账号在试运行环境中产生的数据足迹,不修改业务数据。
Options:
--format=text|json 输出格式,默认 text
--identifier=<value> 追加一个 smoke 账号标识,可重复;匹配 employee_no/email/phone
--help 显示帮助
环境变量:
SMOKE_DATA_IDENTIFIERS 逗号或空白分隔的 smoke 标识,优先级低于 --identifier
`);
}
function parseArgs(argv) {
const options = {
format: "text",
identifiers: [],
help: false,
};
for (const arg of argv) {
if (arg === "--help" || arg === "-h") {
options.help = true;
continue;
}
if (arg.startsWith("--format=")) {
options.format = arg.slice("--format=".length).trim() || "text";
continue;
}
if (arg.startsWith("--identifier=")) {
const value = arg.slice("--identifier=".length).trim();
if (value) options.identifiers.push(value);
continue;
}
throw new Error(`Unsupported argument: ${arg}`);
}
if (!["text", "json"].includes(options.format)) {
throw new Error(`Unsupported --format: ${options.format}`);
}
return options;
}
function parseEnvIdentifiers() {
const raw = String(process.env.SMOKE_DATA_IDENTIFIERS ?? "").trim();
if (!raw) {
return [];
}
return raw
.split(/[\s,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function unique(values) {
return Array.from(new Set(values.filter(Boolean)));
}
function buildEmployeeWhere(identifiers) {
return {
OR: identifiers.flatMap((value) => [
{ employee_no: value },
{ email: value },
{ phone: value },
]),
};
}
async function countByEmployee(prisma, modelName, fieldName, employeeIds) {
if (!employeeIds.length) {
return 0;
}
return prisma[modelName].count({
where: {
[fieldName]: { in: employeeIds },
},
});
}
async function auditSmokeData(prisma, identifiers) {
const employees = await prisma.employee.findMany({
where: buildEmployeeWhere(identifiers),
select: {
id: true,
employee_no: true,
name: true,
email: true,
phone: true,
status: true,
archived_at: true,
},
orderBy: [{ employee_no: "asc" }, { email: "asc" }],
});
const employeeIds = employees.map((item) => item.id);
const reimbursementIds = employeeIds.length
? (
await prisma.reimbursementForm.findMany({
where: {
OR: [
{ owner_id: { in: employeeIds } },
{ submitter_id: { in: employeeIds } },
],
},
select: { id: true },
})
).map((item) => item.id)
: [];
const invoiceRows = employeeIds.length
? await prisma.invoicePool.findMany({
where: {
OR: [
{ owner_employee_id: { in: employeeIds } },
{ uploaded_by_employee_id: { in: employeeIds } },
],
},
select: {
id: true,
status: true,
ocr_result_id: true,
created_at: true,
},
})
: [];
const workflowInstanceIds = employeeIds.length
? (
await prisma.workflowInstance.findMany({
where: {
OR: [
{ submitter_id: { in: employeeIds } },
{ reimbursement_id: { in: reimbursementIds.length ? reimbursementIds : ["00000000-0000-0000-0000-000000000000"] } },
],
},
select: { id: true },
})
).map((item) => item.id)
: [];
const invoiceStatusCounts = invoiceRows.reduce((acc, item) => {
acc[item.status] = (acc[item.status] ?? 0) + 1;
return acc;
}, {});
const ocrResultIds = unique(invoiceRows.map((item) => item.ocr_result_id));
const counts = {
employees: employees.length,
authSessions: await countByEmployee(prisma, "authSession", "employee_id", employeeIds),
reimbursements: reimbursementIds.length,
reimbursementItems: reimbursementIds.length
? await prisma.reimbursementItem.count({ where: { reimbursement_id: { in: reimbursementIds } } })
: 0,
invoices: invoiceRows.length,
ocrResultsLinkedFromInvoices: ocrResultIds.length,
invoiceVerifications: invoiceRows.length
? await prisma.invoiceVerificationRecord.count({
where: { invoice_id: { in: invoiceRows.map((item) => item.id) } },
})
: 0,
leaveRequests: await countByEmployee(prisma, "leaveRequest", "owner_id", employeeIds),
workflowInstances: workflowInstanceIds.length,
workflowTasksAssigned: await countByEmployee(prisma, "workflowTask", "assignee_id", employeeIds),
workflowTasksCompleted: await countByEmployee(prisma, "workflowTask", "completed_by", employeeIds),
workflowHistory: await countByEmployee(prisma, "workflowHistory", "actor_id", employeeIds),
notifications: await countByEmployee(prisma, "notification", "recipient_employee_id", employeeIds),
auditLogs: await countByEmployee(prisma, "auditLog", "actor_id", employeeIds),
};
const latestInvoiceAt = invoiceRows
.map((item) => item.created_at)
.filter(Boolean)
.sort((left, right) => right.getTime() - left.getTime())[0] ?? null;
return {
mode: "read_only",
generatedAt: new Date().toISOString(),
identifiers,
employees,
counts,
invoiceStatusCounts,
latestInvoiceAt: latestInvoiceAt ? latestInvoiceAt.toISOString() : null,
cleanupPolicy: {
recommendation: "本脚本仅审计。若要删除或归档 smoke 数据,请先备份并单独开 destructive cleanup Issue。",
safeScope: "仅允许处理 employee_no/email 明确匹配 smoke 标识的账号产生的数据。",
},
};
}
function printTextReport(report) {
process.stdout.write("[smoke-data-audit] mode=read_only\n");
process.stdout.write(`[smoke-data-audit] generatedAt=${report.generatedAt}\n`);
process.stdout.write(`[smoke-data-audit] identifiers=${report.identifiers.join(", ")}\n`);
if (!report.employees.length) {
process.stdout.write("[smoke-data-audit] 未找到 smoke 员工账号。\n");
return;
}
process.stdout.write("\nEmployees:\n");
for (const employee of report.employees) {
process.stdout.write(
`- ${employee.employee_no} ${employee.email} ${employee.name} status=${employee.status}${
employee.archived_at ? " archived=true" : ""
}\n`,
);
}
process.stdout.write("\nCounts:\n");
for (const [key, value] of Object.entries(report.counts)) {
process.stdout.write(`- ${key}: ${value}\n`);
}
process.stdout.write("\nInvoice status:\n");
const invoiceEntries = Object.entries(report.invoiceStatusCounts);
if (!invoiceEntries.length) {
process.stdout.write("- none\n");
} else {
for (const [status, value] of invoiceEntries) {
process.stdout.write(`- ${status}: ${value}\n`);
}
}
process.stdout.write(`\nLatest invoice: ${report.latestInvoiceAt ?? "none"}\n`);
process.stdout.write(`Recommendation: ${report.cleanupPolicy.recommendation}\n`);
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const identifiers = unique([
...options.identifiers,
...parseEnvIdentifiers(),
...DEFAULT_SMOKE_IDENTIFIERS,
]);
const { prisma, pool } = createPrismaClient();
try {
const report = await auditSmokeData(prisma, identifiers);
if (options.format === "json") {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
} else {
printTextReport(report);
}
} finally {
await prisma.$disconnect();
await pool.end();
}
}
main().catch((error) => {
process.stderr.write(`[smoke-data-audit][FAIL] ${error?.message ?? error}\n`);
process.exitCode = 1;
});