P2D-CORR/P2-ORG 收口只读主数据巡检
这个提交包含在:
@@ -27,6 +27,7 @@
|
||||
"job:verification-once": "node src/scripts/run-verification-once.js",
|
||||
"job:workflow-overdue-reminders": "node src/scripts/run-workflow-overdue-reminders.js",
|
||||
"audit:smoke-data": "node src/scripts/audit-smoke-data.js",
|
||||
"audit:org-master-data": "node src/scripts/audit-org-master-data.js",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
require("dotenv/config");
|
||||
|
||||
const { Pool } = require("pg");
|
||||
|
||||
const BUSINESS_ROLES = new Set(["employee", "manager", "finance", "finance_mgr", "hr", "administration"]);
|
||||
const APPROVAL_ROLES = ["manager", "ceo", "finance", "finance_mgr", "hr", "administration"];
|
||||
const ADMIN_BUSINESS_ROLES = new Set(["manager", "ceo", "finance", "finance_mgr", "hr", "administration"]);
|
||||
const REPORT_LIMIT = 50;
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`Usage: node src/scripts/audit-org-master-data.js [options]
|
||||
|
||||
只读巡检组织账号主数据、主管链、审批待办、角色边界和企业微信绑定漂移。
|
||||
脚本在 PostgreSQL READ ONLY 事务中执行,不修改业务数据。
|
||||
|
||||
Options:
|
||||
--format=text|json 输出格式,默认 text
|
||||
--help 显示帮助
|
||||
|
||||
输出脱敏规则:
|
||||
只输出员工号、状态、角色摘要、问题类型和计数。
|
||||
不输出手机号、邮箱、密码哈希、token、企业微信 external_user_id/open_user_id。
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { format: "text", 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;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
|
||||
if (!["text", "json"].includes(options.format)) {
|
||||
throw new Error(`Unsupported --format: ${options.format}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function isActive(employee) {
|
||||
return employee.status === "active" && !employee.archived;
|
||||
}
|
||||
|
||||
function hasAnyRole(employee, roles) {
|
||||
return (employee.roles ?? []).some((role) => roles.has(role));
|
||||
}
|
||||
|
||||
function isAdminOnly(employee) {
|
||||
const roles = employee.roles ?? [];
|
||||
return roles.includes("admin") && !roles.some((role) => ADMIN_BUSINESS_ROLES.has(role));
|
||||
}
|
||||
|
||||
function sanitizeEmployee(employee) {
|
||||
return {
|
||||
employeeNo: employee.employee_no,
|
||||
status: employee.status,
|
||||
archived: Boolean(employee.archived),
|
||||
roles: employee.roles ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function sample(items) {
|
||||
return items.slice(0, REPORT_LIMIT);
|
||||
}
|
||||
|
||||
function buildManagerChainFindings(employees) {
|
||||
const byId = new Map(employees.map((employee) => [employee.id, employee]));
|
||||
const missingDirectManager = [];
|
||||
const rootNoManager = [];
|
||||
const disabledManager = [];
|
||||
const selfManagerLoop = [];
|
||||
const managerChainCycle = [];
|
||||
|
||||
for (const employee of employees) {
|
||||
if (!isActive(employee)) continue;
|
||||
|
||||
const roles = employee.roles ?? [];
|
||||
const isRootLike = roles.includes("ceo");
|
||||
const isBusinessSubject = hasAnyRole(employee, BUSINESS_ROLES);
|
||||
|
||||
if (!employee.direct_manager_id) {
|
||||
if (isRootLike) {
|
||||
rootNoManager.push(sanitizeEmployee(employee));
|
||||
} else if (isBusinessSubject) {
|
||||
missingDirectManager.push(sanitizeEmployee(employee));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (employee.direct_manager_id === employee.id) {
|
||||
selfManagerLoop.push(sanitizeEmployee(employee));
|
||||
}
|
||||
|
||||
const manager = byId.get(employee.direct_manager_id);
|
||||
if (manager && !isActive(manager)) {
|
||||
disabledManager.push({
|
||||
...sanitizeEmployee(employee),
|
||||
managerEmployeeNo: manager.employee_no,
|
||||
managerStatus: manager.status,
|
||||
managerArchived: Boolean(manager.archived),
|
||||
});
|
||||
}
|
||||
|
||||
const visited = new Set([employee.id]);
|
||||
const chain = [employee.employee_no];
|
||||
let current = manager;
|
||||
let depth = 0;
|
||||
|
||||
while (current && current.direct_manager_id && depth < 50) {
|
||||
chain.push(current.employee_no);
|
||||
if (visited.has(current.id)) {
|
||||
managerChainCycle.push({
|
||||
employeeNo: employee.employee_no,
|
||||
chain: sample(chain),
|
||||
});
|
||||
break;
|
||||
}
|
||||
visited.add(current.id);
|
||||
current = byId.get(current.direct_manager_id);
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
missingDirectManager,
|
||||
rootNoManager,
|
||||
disabledManager,
|
||||
selfManagerLoop,
|
||||
managerChainCycle,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoleFindings(employees, pendingAssignedRows, pendingRoleRows, approvalRuleRows) {
|
||||
const activeEmployees = employees.filter(isActive);
|
||||
const activeRoleCounts = {};
|
||||
|
||||
for (const role of APPROVAL_ROLES) {
|
||||
activeRoleCounts[role] = activeEmployees.filter((employee) => (employee.roles ?? []).includes(role)).length;
|
||||
}
|
||||
|
||||
const configuredRoleWithoutActiveHolder = APPROVAL_ROLES
|
||||
.filter((role) => activeRoleCounts[role] === 0)
|
||||
.map((role) => ({ roleCode: role, activeHolders: 0 }));
|
||||
|
||||
const activeWithoutRoles = activeEmployees
|
||||
.filter((employee) => !employee.roles?.length)
|
||||
.map(sanitizeEmployee);
|
||||
|
||||
const adminOnlyEmployeeIds = new Set(activeEmployees.filter(isAdminOnly).map((employee) => employee.id));
|
||||
const employeeById = new Map(employees.map((employee) => [employee.id, employee]));
|
||||
|
||||
const adminOnlyPendingTasks = pendingAssignedRows
|
||||
.filter((row) => adminOnlyEmployeeIds.has(row.assignee_id))
|
||||
.map((row) => {
|
||||
const employee = employeeById.get(row.assignee_id);
|
||||
return {
|
||||
employeeNo: employee?.employee_no ?? "unknown",
|
||||
entityType: row.entity_type ?? "unknown",
|
||||
taskCount: Number(row.task_count),
|
||||
roles: employee?.roles ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
const financeAdminMixedRoles = activeEmployees
|
||||
.filter((employee) => {
|
||||
const roles = employee.roles ?? [];
|
||||
return roles.includes("admin") && (roles.includes("finance") || roles.includes("finance_mgr"));
|
||||
})
|
||||
.map(sanitizeEmployee);
|
||||
|
||||
return {
|
||||
activeRoleCounts,
|
||||
configuredRoleWithoutActiveHolder,
|
||||
activeWithoutRoles,
|
||||
adminOnlyPendingTasks,
|
||||
financeAdminMixedRoles,
|
||||
pendingRoleWithoutActiveHolder: pendingRoleRows.map((row) => ({
|
||||
roleCode: row.role_code,
|
||||
entityType: row.entity_type ?? "unknown",
|
||||
taskCount: Number(row.task_count),
|
||||
})),
|
||||
approvalRulesUseAdmin: approvalRuleRows.map((row) => ({
|
||||
ruleName: row.name,
|
||||
roles: [row.level1_role, row.level2_role, row.level3_role].filter(Boolean),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildWecomFindings(employees) {
|
||||
const activeEmployees = employees.filter(isActive);
|
||||
const inactiveEmployees = employees.filter((employee) => !isActive(employee));
|
||||
const activeWithoutWecomBinding = activeEmployees
|
||||
.filter((employee) => !employee.has_wecom_binding)
|
||||
.map(sanitizeEmployee);
|
||||
const inactiveWithWecomBinding = inactiveEmployees
|
||||
.filter((employee) => employee.has_wecom_binding)
|
||||
.map(sanitizeEmployee);
|
||||
|
||||
return {
|
||||
bindingSummary: {
|
||||
activeEmployees: activeEmployees.length,
|
||||
activeWithBinding: activeEmployees.length - activeWithoutWecomBinding.length,
|
||||
activeWithoutBinding: activeWithoutWecomBinding.length,
|
||||
inactiveWithBinding: inactiveWithWecomBinding.length,
|
||||
},
|
||||
activeWithoutWecomBinding,
|
||||
inactiveWithWecomBinding,
|
||||
};
|
||||
}
|
||||
|
||||
function classifySeverity(findings) {
|
||||
const high =
|
||||
findings.manager.selfManagerLoop.length +
|
||||
findings.manager.managerChainCycle.length +
|
||||
findings.workflow.pendingInactiveAssignees.length +
|
||||
findings.roles.pendingRoleWithoutActiveHolder.length +
|
||||
findings.roles.adminOnlyPendingTasks.length +
|
||||
findings.roles.approvalRulesUseAdmin.length;
|
||||
|
||||
const medium =
|
||||
findings.manager.missingDirectManager.length +
|
||||
findings.manager.disabledManager.length +
|
||||
findings.roles.configuredRoleWithoutActiveHolder.length +
|
||||
findings.roles.activeWithoutRoles.length +
|
||||
findings.roles.financeAdminMixedRoles.length +
|
||||
findings.wecom.inactiveWithWecomBinding.length;
|
||||
|
||||
const low =
|
||||
findings.manager.rootNoManager.length +
|
||||
findings.wecom.activeWithoutWecomBinding.length;
|
||||
|
||||
return {
|
||||
high,
|
||||
medium,
|
||||
low,
|
||||
recommendation: high > 0 || medium > 0 ? "NEED_P2_ORG_FIX_REVIEW" : "NO_WRITE_FIX_NEEDED",
|
||||
};
|
||||
}
|
||||
|
||||
async function queryAll(client) {
|
||||
const employeesResult = await client.query(`
|
||||
SELECT
|
||||
e.id::text,
|
||||
e.employee_no,
|
||||
e.status::text,
|
||||
(e.archived_at IS NOT NULL) AS archived,
|
||||
e.direct_manager_id::text,
|
||||
dm.employee_no AS manager_employee_no,
|
||||
dm.status::text AS manager_status,
|
||||
(dm.archived_at IS NOT NULL) AS manager_archived,
|
||||
COALESCE(array_remove(array_agg(er.role_code::text ORDER BY er.role_code::text), NULL), ARRAY[]::text[]) AS roles,
|
||||
(w.employee_id IS NOT NULL) AS has_wecom_binding
|
||||
FROM employees e
|
||||
LEFT JOIN employees dm ON dm.id = e.direct_manager_id
|
||||
LEFT JOIN employee_roles er ON er.employee_id = e.id
|
||||
LEFT JOIN wecom_identity_bindings w ON w.employee_id = e.id
|
||||
GROUP BY
|
||||
e.id,
|
||||
e.employee_no,
|
||||
e.status,
|
||||
e.archived_at,
|
||||
e.direct_manager_id,
|
||||
dm.employee_no,
|
||||
dm.status,
|
||||
dm.archived_at,
|
||||
w.employee_id
|
||||
ORDER BY e.employee_no ASC
|
||||
`);
|
||||
|
||||
const pendingInactiveResult = await client.query(`
|
||||
SELECT
|
||||
e.employee_no,
|
||||
e.status::text AS employee_status,
|
||||
(e.archived_at IS NOT NULL) AS archived,
|
||||
wi.entity_type,
|
||||
count(*)::int AS task_count
|
||||
FROM workflow_tasks t
|
||||
JOIN employees e ON e.id = t.assignee_id
|
||||
LEFT JOIN workflow_instances wi ON wi.id = t.instance_id
|
||||
WHERE t.status = 'pending'
|
||||
AND t.assignee_id IS NOT NULL
|
||||
AND (e.status <> 'active' OR e.archived_at IS NOT NULL)
|
||||
GROUP BY e.employee_no, e.status, e.archived_at, wi.entity_type
|
||||
ORDER BY task_count DESC, e.employee_no ASC
|
||||
`);
|
||||
|
||||
const pendingAssignedResult = await client.query(`
|
||||
SELECT
|
||||
t.assignee_id::text,
|
||||
wi.entity_type,
|
||||
count(*)::int AS task_count
|
||||
FROM workflow_tasks t
|
||||
LEFT JOIN workflow_instances wi ON wi.id = t.instance_id
|
||||
WHERE t.status = 'pending'
|
||||
AND t.assignee_id IS NOT NULL
|
||||
GROUP BY t.assignee_id, wi.entity_type
|
||||
`);
|
||||
|
||||
const pendingRoleResult = await client.query(`
|
||||
SELECT
|
||||
t.assignee_role::text AS role_code,
|
||||
wi.entity_type,
|
||||
count(*)::int AS task_count
|
||||
FROM workflow_tasks t
|
||||
LEFT JOIN workflow_instances wi ON wi.id = t.instance_id
|
||||
WHERE t.status = 'pending'
|
||||
AND t.assignee_role IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM employee_roles er
|
||||
JOIN employees e ON e.id = er.employee_id
|
||||
WHERE er.role_code = t.assignee_role
|
||||
AND e.status = 'active'
|
||||
AND e.archived_at IS NULL
|
||||
)
|
||||
GROUP BY t.assignee_role, wi.entity_type
|
||||
ORDER BY task_count DESC, role_code ASC
|
||||
`);
|
||||
|
||||
const approvalRuleResult = await client.query(`
|
||||
SELECT name, level1_role::text, level2_role::text, level3_role::text
|
||||
FROM approval_rules
|
||||
WHERE enabled = true
|
||||
AND (
|
||||
level1_role = 'admin'
|
||||
OR level2_role = 'admin'
|
||||
OR level3_role = 'admin'
|
||||
)
|
||||
ORDER BY priority ASC, name ASC
|
||||
`);
|
||||
|
||||
return {
|
||||
employees: employeesResult.rows,
|
||||
pendingInactiveAssignees: pendingInactiveResult.rows.map((row) => ({
|
||||
employeeNo: row.employee_no,
|
||||
employeeStatus: row.employee_status,
|
||||
archived: Boolean(row.archived),
|
||||
entityType: row.entity_type ?? "unknown",
|
||||
taskCount: Number(row.task_count),
|
||||
})),
|
||||
pendingAssignedRows: pendingAssignedResult.rows,
|
||||
pendingRoleRows: pendingRoleResult.rows,
|
||||
approvalRuleRows: approvalRuleResult.rows,
|
||||
};
|
||||
}
|
||||
|
||||
async function auditOrgMasterData() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("Missing DATABASE_URL");
|
||||
}
|
||||
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN READ ONLY");
|
||||
const queried = await queryAll(client);
|
||||
await client.query("COMMIT");
|
||||
|
||||
const manager = buildManagerChainFindings(queried.employees);
|
||||
const roles = buildRoleFindings(
|
||||
queried.employees,
|
||||
queried.pendingAssignedRows,
|
||||
queried.pendingRoleRows,
|
||||
queried.approvalRuleRows,
|
||||
);
|
||||
const wecom = buildWecomFindings(queried.employees);
|
||||
const workflow = {
|
||||
pendingInactiveAssignees: queried.pendingInactiveAssignees,
|
||||
};
|
||||
const findings = { manager, workflow, roles, wecom };
|
||||
|
||||
return {
|
||||
mode: "read_only",
|
||||
generatedAt: new Date().toISOString(),
|
||||
privacy: "employee_no/status/roles only; no phone/email/password/token/wecom external ids",
|
||||
totals: {
|
||||
employees: queried.employees.length,
|
||||
activeEmployees: queried.employees.filter(isActive).length,
|
||||
},
|
||||
severity: classifySeverity(findings),
|
||||
findings: {
|
||||
manager: {
|
||||
missingDirectManager: {
|
||||
count: manager.missingDirectManager.length,
|
||||
items: sample(manager.missingDirectManager),
|
||||
},
|
||||
rootNoManager: {
|
||||
count: manager.rootNoManager.length,
|
||||
items: sample(manager.rootNoManager),
|
||||
},
|
||||
disabledManager: {
|
||||
count: manager.disabledManager.length,
|
||||
items: sample(manager.disabledManager),
|
||||
},
|
||||
selfManagerLoop: {
|
||||
count: manager.selfManagerLoop.length,
|
||||
items: sample(manager.selfManagerLoop),
|
||||
},
|
||||
managerChainCycle: {
|
||||
count: manager.managerChainCycle.length,
|
||||
items: sample(manager.managerChainCycle),
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
pendingInactiveAssignees: {
|
||||
count: workflow.pendingInactiveAssignees.length,
|
||||
items: sample(workflow.pendingInactiveAssignees),
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
activeRoleCounts: roles.activeRoleCounts,
|
||||
configuredRoleWithoutActiveHolder: {
|
||||
count: roles.configuredRoleWithoutActiveHolder.length,
|
||||
items: roles.configuredRoleWithoutActiveHolder,
|
||||
},
|
||||
activeWithoutRoles: {
|
||||
count: roles.activeWithoutRoles.length,
|
||||
items: sample(roles.activeWithoutRoles),
|
||||
},
|
||||
pendingRoleWithoutActiveHolder: {
|
||||
count: roles.pendingRoleWithoutActiveHolder.length,
|
||||
items: sample(roles.pendingRoleWithoutActiveHolder),
|
||||
},
|
||||
adminOnlyPendingTasks: {
|
||||
count: roles.adminOnlyPendingTasks.length,
|
||||
items: sample(roles.adminOnlyPendingTasks),
|
||||
},
|
||||
approvalRulesUseAdmin: {
|
||||
count: roles.approvalRulesUseAdmin.length,
|
||||
items: sample(roles.approvalRulesUseAdmin),
|
||||
},
|
||||
financeAdminMixedRoles: {
|
||||
count: roles.financeAdminMixedRoles.length,
|
||||
items: sample(roles.financeAdminMixedRoles),
|
||||
},
|
||||
},
|
||||
wecom: {
|
||||
bindingSummary: wecom.bindingSummary,
|
||||
activeWithoutWecomBinding: {
|
||||
count: wecom.activeWithoutWecomBinding.length,
|
||||
items: sample(wecom.activeWithoutWecomBinding),
|
||||
},
|
||||
inactiveWithWecomBinding: {
|
||||
count: wecom.inactiveWithWecomBinding.length,
|
||||
items: sample(wecom.inactiveWithWecomBinding),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
try {
|
||||
await client.query("ROLLBACK");
|
||||
} catch {
|
||||
// Ignore rollback failures; preserve the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function writeFindingLine(label, finding) {
|
||||
process.stdout.write(`- ${label}: ${finding.count}\n`);
|
||||
for (const item of finding.items ?? []) {
|
||||
process.stdout.write(` ${JSON.stringify(item)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function printTextReport(report) {
|
||||
process.stdout.write("[org-master-data-audit] mode=read_only\n");
|
||||
process.stdout.write(`[org-master-data-audit] generatedAt=${report.generatedAt}\n`);
|
||||
process.stdout.write(`[org-master-data-audit] privacy=${report.privacy}\n`);
|
||||
process.stdout.write(`[org-master-data-audit] employees=${report.totals.employees} active=${report.totals.activeEmployees}\n`);
|
||||
process.stdout.write(
|
||||
`[org-master-data-audit] severity high=${report.severity.high} medium=${report.severity.medium} low=${report.severity.low} recommendation=${report.severity.recommendation}\n`,
|
||||
);
|
||||
|
||||
process.stdout.write("\nManager chain:\n");
|
||||
writeFindingLine("missingDirectManager", report.findings.manager.missingDirectManager);
|
||||
writeFindingLine("rootNoManager", report.findings.manager.rootNoManager);
|
||||
writeFindingLine("disabledManager", report.findings.manager.disabledManager);
|
||||
writeFindingLine("selfManagerLoop", report.findings.manager.selfManagerLoop);
|
||||
writeFindingLine("managerChainCycle", report.findings.manager.managerChainCycle);
|
||||
|
||||
process.stdout.write("\nWorkflow:\n");
|
||||
writeFindingLine("pendingInactiveAssignees", report.findings.workflow.pendingInactiveAssignees);
|
||||
|
||||
process.stdout.write("\nRoles:\n");
|
||||
process.stdout.write(`- activeRoleCounts: ${JSON.stringify(report.findings.roles.activeRoleCounts)}\n`);
|
||||
writeFindingLine("configuredRoleWithoutActiveHolder", report.findings.roles.configuredRoleWithoutActiveHolder);
|
||||
writeFindingLine("activeWithoutRoles", report.findings.roles.activeWithoutRoles);
|
||||
writeFindingLine("pendingRoleWithoutActiveHolder", report.findings.roles.pendingRoleWithoutActiveHolder);
|
||||
writeFindingLine("adminOnlyPendingTasks", report.findings.roles.adminOnlyPendingTasks);
|
||||
writeFindingLine("approvalRulesUseAdmin", report.findings.roles.approvalRulesUseAdmin);
|
||||
writeFindingLine("financeAdminMixedRoles", report.findings.roles.financeAdminMixedRoles);
|
||||
|
||||
process.stdout.write("\nWeCom binding:\n");
|
||||
process.stdout.write(`- bindingSummary: ${JSON.stringify(report.findings.wecom.bindingSummary)}\n`);
|
||||
writeFindingLine("activeWithoutWecomBinding", report.findings.wecom.activeWithoutWecomBinding);
|
||||
writeFindingLine("inactiveWithWecomBinding", report.findings.wecom.inactiveWithWecomBinding);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const report = await auditOrgMasterData();
|
||||
if (options.format === "json") {
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
} else {
|
||||
printTextReport(report);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[org-master-data-audit][FAIL] ${error?.message ?? error}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户