R105/A2-A5 补强试运行自动化验收
这个提交包含在:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户