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, row.owner_self_fallback_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, owner_self_fallback_role::text FROM approval_rules WHERE enabled = true AND ( level1_role = 'admin' OR level2_role = 'admin' OR level3_role = 'admin' OR owner_self_fallback_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; });