#!/usr/bin/env node require("dotenv/config"); const fs = require("node:fs"); const path = require("node:path"); const { Pool } = require("pg"); const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow"); const MAX_REPORT_ITEMS = 200; function timestamp() { return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); } function printHelp() { process.stdout.write(`Usage: node src/scripts/resolve-wecom-oa-user-consistency.js --report [options] 受控处理 WOUA 只读报告中的 OA/企微用户一致性差异。 默认只 dry-run;真实写 OA 必须同时指定 --apply 且设置 WOUA_FIX_APPLY=1。 Options: --report= WOUA summary.json 路径,必填。 --dry-run 只生成计划,不写数据库,默认。 --apply 执行受控 OA 写入,需 WOUA_FIX_APPLY=1。 --allow-employee-no= 限定允许处理的员工号;可重复。 --oa-only-smoke-employee-no= 明确标记 OA-only smoke 员工号;这些员工缺企微账号时不归档。 --clear-inactive-bindings 显式清理 inactiveWithBinding 中的绑定;默认不清理。 --repair-manager-effects 将归档测试主管的 active 下属和 pending 待办转给 fallback active manager。 --manager-fallback-employee-no= 指定 fallback manager;未指定时选择 active 且有企微绑定的 manager。 --format=text|json 输出格式,默认 text。 --report-dir= 输出目录,默认 artifacts/qa-flow/wecom-oa-user-consistency-fix-。 --help 显示帮助。 隐私边界: 输出只包含员工号、状态、角色、分类、计数和处理结果。 不输出手机号、邮箱、密码哈希、token、secret、完整企微 userid 或企微原始 payload。 `); } function parseArgs(argv) { const options = { reportPath: process.env.WOUA_FIX_REPORT || "", mode: "dry_run", format: "text", reportDir: process.env.WOUA_FIX_REPORT_DIR || path.join(DEFAULT_REPORT_ROOT, `wecom-oa-user-consistency-fix-${timestamp()}`), allowEmployeeNos: [], oaOnlySmokeEmployeeNos: splitList(process.env.OA_ONLY_SMOKE_EMPLOYEE_NOS || ""), clearInactiveBindings: false, repairManagerEffects: false, managerFallbackEmployeeNo: "", help: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--help" || arg === "-h") { options.help = true; } else if (arg === "--report") { options.reportPath = argv[index + 1] || ""; index += 1; } else if (arg.startsWith("--report=")) { options.reportPath = arg.slice("--report=".length); } else if (arg === "--dry-run") { options.mode = "dry_run"; } else if (arg === "--apply") { options.mode = "apply"; } else if (arg === "--format") { options.format = argv[index + 1] || options.format; index += 1; } else if (arg.startsWith("--format=")) { options.format = arg.slice("--format=".length); } else if (arg === "--report-dir") { options.reportDir = argv[index + 1] || options.reportDir; index += 1; } else if (arg.startsWith("--report-dir=")) { options.reportDir = arg.slice("--report-dir=".length); } else if (arg === "--allow-employee-no") { options.allowEmployeeNos.push(...splitList(argv[index + 1] || "")); index += 1; } else if (arg.startsWith("--allow-employee-no=")) { options.allowEmployeeNos.push(...splitList(arg.slice("--allow-employee-no=".length))); } else if (arg === "--oa-only-smoke-employee-no") { options.oaOnlySmokeEmployeeNos.push(...splitList(argv[index + 1] || "")); index += 1; } else if (arg.startsWith("--oa-only-smoke-employee-no=")) { options.oaOnlySmokeEmployeeNos.push(...splitList(arg.slice("--oa-only-smoke-employee-no=".length))); } else if (arg === "--clear-inactive-bindings") { options.clearInactiveBindings = true; } else if (arg === "--repair-manager-effects") { options.repairManagerEffects = true; } else if (arg === "--manager-fallback-employee-no") { options.managerFallbackEmployeeNo = normalizeEmployeeNo(argv[index + 1] || ""); index += 1; } else if (arg.startsWith("--manager-fallback-employee-no=")) { options.managerFallbackEmployeeNo = normalizeEmployeeNo(arg.slice("--manager-fallback-employee-no=".length)); } else { throw new Error(`Unsupported argument: ${arg}`); } } if (!["text", "json"].includes(options.format)) { throw new Error(`Unsupported --format: ${options.format}`); } options.allowEmployeeNos = normalizeEmployeeNoList(options.allowEmployeeNos); options.oaOnlySmokeEmployeeNos = normalizeEmployeeNoList(options.oaOnlySmokeEmployeeNos); return options; } function splitList(value) { return String(value ?? "") .split(",") .map((item) => item.trim()) .filter(Boolean); } function normalizeEmployeeNo(value) { return String(value ?? "").trim().slice(0, 128); } function normalizeEmployeeNoList(values) { const seen = new Set(); const items = []; for (const value of values ?? []) { const employeeNo = normalizeEmployeeNo(value); if (!employeeNo || seen.has(employeeNo)) continue; seen.add(employeeNo); items.push(employeeNo); } return items; } function sample(items) { return items.slice(0, MAX_REPORT_ITEMS); } function sanitizeRoles(roles) { return Array.isArray(roles) ? roles.filter((role) => typeof role === "string").sort() : []; } function sanitizeErrorMessage(value) { return String(value ?? "") .replace(/access_token=[^&\s]+/gi, "access_token=***") .replace(/[A-Za-z0-9_-]{24,}/g, "***") .slice(0, 300); } function readJsonFile(filePath) { if (!filePath) { throw new Error("Missing --report"); } const absolute = path.resolve(filePath); return JSON.parse(fs.readFileSync(absolute, "utf8")); } function employeeNoFromFinding(item) { return normalizeEmployeeNo(item?.employeeNo ?? item?.employee?.employeeNo); } function buildEmployeeNoActions(items, action, reason) { return normalizeEmployeeNoList((items ?? []).map(employeeNoFromFinding)).map((employeeNo) => ({ employeeNo, action, reason, })); } function buildAcceptedMissingRequiredFields(items) { return sample(items ?? []).map((item) => ({ employeeNo: employeeNoFromFinding(item), fields: Array.isArray(item?.fields) ? [...item.fields].sort() : [], action: "accepted_no_write", reason: "user_confirmed_do_not_fill_wecom_required_fields_now", })).filter((item) => item.employeeNo); } function buildFixPlan(report, options = {}) { const findings = report?.findings ?? {}; const allowSet = new Set(normalizeEmployeeNoList(options.allowEmployeeNos)); const oaOnlySmokeSet = new Set(normalizeEmployeeNoList(options.oaOnlySmokeEmployeeNos)); const shouldInclude = (item) => !allowSet.size || allowSet.has(item.employeeNo); const archiveSource = buildEmployeeNoActions( findings.oaActiveMissingInWecom, "archive_oa_test_employee", "user_confirmed_oa_active_missing_in_wecom_are_test_data", ); const oaOnlySmokeExempted = [ ...buildEmployeeNoActions( findings.oaOnlySmokeExempted, "oa_only_smoke_exempted", "source_report_classified_oa_only_smoke", ), ...archiveSource .filter((item) => oaOnlySmokeSet.has(item.employeeNo)) .map((item) => ({ employeeNo: item.employeeNo, action: "oa_only_smoke_exempted", reason: "explicit_oa_only_smoke_employee_no", })), ]; const exemptSet = new Set(oaOnlySmokeExempted.map((item) => item.employeeNo)); const archiveCandidates = archiveSource .filter((item) => !exemptSet.has(item.employeeNo)) .filter(shouldInclude); const excludedByAllowlist = allowSet.size ? archiveSource .filter((item) => !exemptSet.has(item.employeeNo)) .map((item) => ({ employeeNo: item.employeeNo, action: "excluded_by_allowlist", reason: "allowlist_did_not_include_employee_no", })) .filter((item) => !allowSet.has(item.employeeNo)) : []; const inactiveBindingAction = options.clearInactiveBindings ? "clear_inactive_wecom_binding" : "accepted_no_write"; return { sourceStatus: report?.status ?? "UNKNOWN", sourceConclusion: report?.conclusion ?? null, sourceGeneratedAt: report?.generatedAt ?? null, decisions: { oaActiveMissingInWecom: "archive_oa_test_employees", oaOnlySmokeMissingInWecom: "exempt_no_archive", missingRequiredFields: "accepted_no_write", inactiveWithBinding: inactiveBindingAction, managerSideEffects: options.repairManagerEffects ? "repair_to_active_manager" : "report_only", wecomWriteBehavior: "no_wecom_writes", }, archiveOaTestEmployees: archiveCandidates, oaOnlySmokeExempted, excludedByAllowlist, acceptedMissingRequiredFields: buildAcceptedMissingRequiredFields(findings.missingRequiredFields), inactiveWithBinding: buildEmployeeNoActions( findings.inactiveWithBinding, inactiveBindingAction, options.clearInactiveBindings ? "explicit_clear_inactive_bindings" : "binding_retained_for_audit_by_default", ), }; } function assertApplyAllowed(options) { if (options.mode !== "apply") return; if (process.env.WOUA_FIX_APPLY !== "1") { throw new Error("Refusing apply without WOUA_FIX_APPLY=1"); } } function buildDryRunResult(plan, options = {}) { const inactiveClearCount = options.clearInactiveBindings ? plan.inactiveWithBinding.length : 0; return { mode: "dry_run", generatedAt: new Date().toISOString(), status: "DRY_RUN", privacy: "employee_no/status/roles/counts only; no phone/email/password/token/secret/full userid/raw payload", source: { status: plan.sourceStatus, conclusion: plan.sourceConclusion, generatedAt: plan.sourceGeneratedAt, }, decisions: plan.decisions, totals: { archiveOaTestEmployees: plan.archiveOaTestEmployees.length, oaOnlySmokeExempted: plan.oaOnlySmokeExempted.length, excludedByAllowlist: plan.excludedByAllowlist.length, acceptedMissingRequiredFields: plan.acceptedMissingRequiredFields.length, inactiveWithBinding: plan.inactiveWithBinding.length, inactiveBindingsToClear: inactiveClearCount, managerSideEffectRepairs: options.repairManagerEffects ? plan.archiveOaTestEmployees.length : 0, wecomWrites: 0, }, actions: { archiveOaTestEmployees: sample(plan.archiveOaTestEmployees), oaOnlySmokeExempted: sample(plan.oaOnlySmokeExempted), excludedByAllowlist: sample(plan.excludedByAllowlist), acceptedMissingRequiredFields: sample(plan.acceptedMissingRequiredFields), inactiveWithBinding: sample(plan.inactiveWithBinding), }, }; } async function queryEmployeesForNos(client, employeeNos) { if (!employeeNos.length) return []; const result = await client.query( ` SELECT e.id::text, e.employee_no, e.status::text, e.employment_status::text, (e.archived_at IS NOT NULL) AS archived, e.token_version, 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, ( SELECT COUNT(*)::int FROM workflow_tasks wt WHERE wt.assignee_id = e.id AND wt.status = 'pending' ) AS pending_workflow_tasks, ( SELECT COUNT(*)::int FROM employees dr WHERE dr.direct_manager_id = e.id AND dr.status = 'active' AND dr.archived_at IS NULL ) AS active_direct_reports, ( SELECT COUNT(*)::int FROM auth_sessions s WHERE s.employee_id = e.id AND s.revoked_at IS NULL ) AS active_auth_sessions FROM employees e LEFT JOIN employee_roles er ON er.employee_id = e.id LEFT JOIN wecom_identity_bindings w ON w.employee_id = e.id WHERE e.employee_no = ANY($1::text[]) GROUP BY e.id, e.employee_no, e.status, e.employment_status, e.archived_at, e.token_version, w.employee_id ORDER BY e.employee_no ASC `, [employeeNos], ); return result.rows.map((row) => ({ id: row.id, employeeNo: row.employee_no, status: row.status, employmentStatus: row.employment_status, archived: Boolean(row.archived), tokenVersion: Number(row.token_version ?? 0), roles: sanitizeRoles(row.roles), hasWecomBinding: Boolean(row.has_wecom_binding), pendingWorkflowTasks: Number(row.pending_workflow_tasks ?? 0), activeDirectReports: Number(row.active_direct_reports ?? 0), activeAuthSessions: Number(row.active_auth_sessions ?? 0), })); } function mapRowsByEmployeeNo(rows) { const map = new Map(); for (const row of rows) { map.set(row.employeeNo, row); } return map; } function summarizeArchiveItem(planned, row, result) { return { employeeNo: planned.employeeNo, action: result.action, result: result.result, reason: result.reason ?? planned.reason, before: row ? { status: row.status, employmentStatus: row.employmentStatus, archived: row.archived, roles: row.roles, hasWecomBinding: row.hasWecomBinding, pendingWorkflowTasks: row.pendingWorkflowTasks, activeDirectReports: row.activeDirectReports, activeAuthSessions: row.activeAuthSessions, } : null, }; } async function archiveEmployee(client, row, now, dryRun) { if (!row) { return { action: "archive_oa_test_employee", result: "skipped", reason: "employee_not_found" }; } if (row.status !== "active" || row.archived) { return { action: "archive_oa_test_employee", result: "noop", reason: "already_inactive_or_archived" }; } if (dryRun) { return { action: "archive_oa_test_employee", result: "planned", reason: "dry_run" }; } await client.query( ` UPDATE employees SET status = 'archived', employment_status = 'inactive', archived_at = COALESCE(archived_at, $2::timestamptz), token_version = token_version + 1, updated_at = $2::timestamptz, updated_by = NULL WHERE id = $1::uuid AND status = 'active' AND archived_at IS NULL `, [row.id, now.toISOString()], ); await client.query( ` INSERT INTO employee_lifecycle_events (id, employee_id, event_type, effective_date, payload_json, created_at, created_by) VALUES (gen_random_uuid(), $1::uuid, 'offboard', $2::date, $3::jsonb, $4::timestamptz, NULL) `, [ row.id, now.toISOString().slice(0, 10), JSON.stringify({ source: "WOUA-FIX", reason: "oa_active_missing_in_wecom_test_data_cleanup", }), now.toISOString(), ], ); return { action: "archive_oa_test_employee", result: "applied", reason: "archived_and_token_invalidated" }; } async function clearInactiveBinding(client, row, now, dryRun) { if (!row) { return { action: "clear_inactive_wecom_binding", result: "skipped", reason: "employee_not_found" }; } if (row.status === "active" && !row.archived) { return { action: "clear_inactive_wecom_binding", result: "skipped", reason: "employee_is_active" }; } if (!row.hasWecomBinding) { return { action: "clear_inactive_wecom_binding", result: "noop", reason: "binding_not_found" }; } if (dryRun) { return { action: "clear_inactive_wecom_binding", result: "planned", reason: "dry_run" }; } await client.query( "DELETE FROM wecom_identity_bindings WHERE employee_id = $1::uuid", [row.id], ); await client.query( ` INSERT INTO employee_lifecycle_events (id, employee_id, event_type, effective_date, payload_json, created_at, created_by) VALUES (gen_random_uuid(), $1::uuid, 'offboard', $2::date, $3::jsonb, $4::timestamptz, NULL) `, [ row.id, now.toISOString().slice(0, 10), JSON.stringify({ source: "WOUA-FIX", reason: "inactive_wecom_binding_cleared_explicitly", }), now.toISOString(), ], ); return { action: "clear_inactive_wecom_binding", result: "applied", reason: "binding_deleted" }; } async function resolveManagerFallback(client, options, excludedEmployeeIds) { if (options.managerFallbackEmployeeNo) { const result = await client.query( ` SELECT e.id::text, e.employee_no FROM employees e JOIN employee_roles er ON er.employee_id = e.id AND er.role_code = 'manager' WHERE e.employee_no = $1 AND e.status = 'active' AND e.archived_at IS NULL LIMIT 1 `, [options.managerFallbackEmployeeNo], ); return result.rows[0] ? { id: result.rows[0].id, employeeNo: result.rows[0].employee_no, source: "explicit" } : null; } const result = await client.query( ` SELECT e.id::text, e.employee_no FROM employees e JOIN employee_roles er ON er.employee_id = e.id AND er.role_code = 'manager' JOIN wecom_identity_bindings w ON w.employee_id = e.id WHERE e.status = 'active' AND e.archived_at IS NULL AND NOT (e.id = ANY($1::uuid[])) ORDER BY e.employee_no ASC LIMIT 1 `, [excludedEmployeeIds], ); return result.rows[0] ? { id: result.rows[0].id, employeeNo: result.rows[0].employee_no, source: "auto_active_bound_manager" } : null; } async function repairArchivedManagerEffects(client, archivedRows, options, now, dryRun) { const managerRows = archivedRows.filter( (row) => row?.id && (row.activeDirectReports > 0 || row.pendingWorkflowTasks > 0), ); if (!managerRows.length) return []; const fallback = await resolveManagerFallback(client, options, archivedRows.map((row) => row.id)); if (!fallback) { return managerRows.map((row) => ({ employeeNo: row.employeeNo, action: "repair_manager_side_effects", result: "skipped", reason: "active_manager_fallback_not_found", fallbackEmployeeNo: null, directReports: row.activeDirectReports, pendingWorkflowTasks: row.pendingWorkflowTasks, })); } const repairs = []; for (const row of managerRows) { const directReports = await client.query( ` SELECT employee_no FROM employees WHERE direct_manager_id = $1::uuid AND status = 'active' AND archived_at IS NULL ORDER BY employee_no ASC `, [row.id], ); const taskCountResult = await client.query( ` SELECT COUNT(*)::int AS task_count FROM workflow_tasks WHERE assignee_id = $1::uuid AND status = 'pending' `, [row.id], ); const pendingWorkflowTasks = Number(taskCountResult.rows[0]?.task_count ?? 0); const directReportNos = directReports.rows.map((item) => item.employee_no); if (!dryRun) { await client.query( ` UPDATE employees SET direct_manager_id = $2::uuid, updated_at = $3::timestamptz, updated_by = NULL WHERE direct_manager_id = $1::uuid AND status = 'active' AND archived_at IS NULL `, [row.id, fallback.id, now.toISOString()], ); await client.query( ` UPDATE workflow_tasks SET assignee_id = $2::uuid WHERE assignee_id = $1::uuid AND status = 'pending' `, [row.id, fallback.id], ); } repairs.push({ employeeNo: row.employeeNo, action: "repair_manager_side_effects", result: dryRun ? "planned" : "applied", fallbackEmployeeNo: fallback.employeeNo, fallbackSource: fallback.source, directReports: directReportNos.length, directReportEmployeeNos: sample(directReportNos), pendingWorkflowTasks, }); } return repairs; } async function applyPlanWithClient(client, plan, options = {}) { const dryRun = options.mode !== "apply"; const now = options.now ?? new Date(); const archiveNos = plan.archiveOaTestEmployees.map((item) => item.employeeNo); const inactiveNos = plan.inactiveWithBinding.map((item) => item.employeeNo); const employeeNos = normalizeEmployeeNoList([...archiveNos, ...inactiveNos]); await client.query("BEGIN"); try { const rows = await queryEmployeesForNos(client, employeeNos); const byEmployeeNo = mapRowsByEmployeeNo(rows); const archived = []; const inactiveBindings = []; for (const planned of plan.archiveOaTestEmployees) { const row = byEmployeeNo.get(planned.employeeNo) ?? null; const result = await archiveEmployee(client, row, now, dryRun); archived.push(summarizeArchiveItem(planned, row, result)); } const archivedRows = plan.archiveOaTestEmployees .map((planned) => byEmployeeNo.get(planned.employeeNo)) .filter(Boolean); const managerRepairs = options.repairManagerEffects ? await repairArchivedManagerEffects(client, archivedRows, options, now, dryRun) : []; for (const planned of plan.inactiveWithBinding) { const row = byEmployeeNo.get(planned.employeeNo) ?? null; if (!options.clearInactiveBindings) { inactiveBindings.push(summarizeArchiveItem(planned, row, { action: "accepted_no_write", result: "accepted", reason: "binding_retained_for_audit_by_default", })); continue; } const result = await clearInactiveBinding(client, row, now, dryRun); inactiveBindings.push(summarizeArchiveItem(planned, row, result)); } if (dryRun) { await client.query("ROLLBACK"); } else { await client.query("COMMIT"); } return buildDatabaseResult({ plan, archived, inactiveBindings, managerRepairs, options, now }); } catch (error) { try { await client.query("ROLLBACK"); } catch { // Preserve original error. } throw error; } } function countByResult(items, result) { return items.filter((item) => item.result === result).length; } function buildDatabaseResult({ plan, archived, inactiveBindings, managerRepairs = [], options, now }) { const mode = options.mode === "apply" ? "apply" : "dry_run"; return { mode, generatedAt: now.toISOString(), status: mode === "apply" ? "APPLIED" : "DRY_RUN", privacy: "employee_no/status/roles/counts only; no phone/email/password/token/secret/full userid/raw payload", source: { status: plan.sourceStatus, conclusion: plan.sourceConclusion, generatedAt: plan.sourceGeneratedAt, }, decisions: plan.decisions, totals: { archiveOaTestEmployees: archived.length, oaOnlySmokeExempted: plan.oaOnlySmokeExempted.length, archivedApplied: countByResult(archived, "applied"), archivedPlanned: countByResult(archived, "planned"), archivedNoop: countByResult(archived, "noop"), archivedSkipped: countByResult(archived, "skipped"), excludedByAllowlist: plan.excludedByAllowlist.length, acceptedMissingRequiredFields: plan.acceptedMissingRequiredFields.length, inactiveWithBinding: inactiveBindings.length, inactiveBindingApplied: countByResult(inactiveBindings, "applied"), inactiveBindingAccepted: countByResult(inactiveBindings, "accepted"), inactiveBindingPlanned: countByResult(inactiveBindings, "planned"), inactiveBindingSkipped: countByResult(inactiveBindings, "skipped"), managerSideEffectRepairs: managerRepairs.length, managerSideEffectRepairApplied: countByResult(managerRepairs, "applied"), managerSideEffectRepairPlanned: countByResult(managerRepairs, "planned"), managerSideEffectRepairSkipped: countByResult(managerRepairs, "skipped"), wecomWrites: 0, }, actions: { archiveOaTestEmployees: sample(archived), oaOnlySmokeExempted: sample(plan.oaOnlySmokeExempted), excludedByAllowlist: sample(plan.excludedByAllowlist), acceptedMissingRequiredFields: sample(plan.acceptedMissingRequiredFields), inactiveWithBinding: sample(inactiveBindings), managerSideEffectRepairs: sample(managerRepairs), }, }; } async function executeResolve(options) { const report = readJsonFile(options.reportPath); const plan = buildFixPlan(report, options); assertApplyAllowed(options); if (!process.env.DATABASE_URL) { if (options.mode === "dry_run") { return buildDryRunResult(plan, options); } throw new Error("Missing DATABASE_URL"); } const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const client = await pool.connect(); try { return await applyPlanWithClient(client, plan, options); } finally { client.release(); await pool.end(); } } function ensureReportDir(reportDir) { fs.mkdirSync(reportDir, { recursive: true }); } function writeReport(reportDir, report) { ensureReportDir(reportDir); const filePath = path.join(reportDir, "summary.json"); fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); return filePath; } function printText(report, reportPath) { process.stdout.write(`[wecom-oa-user-consistency-fix] status=${report.status} mode=${report.mode}\n`); process.stdout.write(`[wecom-oa-user-consistency-fix] report=${reportPath}\n`); process.stdout.write(`[wecom-oa-user-consistency-fix] privacy=${report.privacy}\n`); process.stdout.write(`[wecom-oa-user-consistency-fix] decisions=${JSON.stringify(report.decisions)}\n`); process.stdout.write(`[wecom-oa-user-consistency-fix] totals=${JSON.stringify(report.totals)}\n`); } async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { printHelp(); return; } const report = await executeResolve(options); const reportPath = writeReport(options.reportDir, report); if (options.format === "json") { process.stdout.write(`${JSON.stringify({ ...report, reportPath }, null, 2)}\n`); } else { printText(report, reportPath); } } if (require.main === module) { main().catch((error) => { process.stderr.write(`[wecom-oa-user-consistency-fix][FAIL] ${sanitizeErrorMessage(error?.message || error)}\n`); process.exitCode = 1; }); } module.exports = { applyPlanWithClient, assertApplyAllowed, buildDryRunResult, buildFixPlan, executeResolve, normalizeEmployeeNoList, parseArgs, };