749 行
26 KiB
JavaScript
749 行
26 KiB
JavaScript
#!/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 <summary.json> [options]
|
|
|
|
受控处理 WOUA 只读报告中的 OA/企微用户一致性差异。
|
|
默认只 dry-run;真实写 OA 必须同时指定 --apply 且设置 WOUA_FIX_APPLY=1。
|
|
|
|
Options:
|
|
--report=<path> WOUA summary.json 路径,必填。
|
|
--dry-run 只生成计划,不写数据库,默认。
|
|
--apply 执行受控 OA 写入,需 WOUA_FIX_APPLY=1。
|
|
--allow-employee-no=<no[,no]> 限定允许处理的员工号;可重复。
|
|
--oa-only-smoke-employee-no=<no[,no]>
|
|
明确标记 OA-only smoke 员工号;这些员工缺企微账号时不归档。
|
|
--clear-inactive-bindings 显式清理 inactiveWithBinding 中的绑定;默认不清理。
|
|
--repair-manager-effects 将归档测试主管的 active 下属和 pending 待办转给 fallback active manager。
|
|
--manager-fallback-employee-no=<no>
|
|
指定 fallback manager;未指定时选择 active 且有企微绑定的 manager。
|
|
--format=text|json 输出格式,默认 text。
|
|
--report-dir=<path> 输出目录,默认 artifacts/qa-flow/wecom-oa-user-consistency-fix-<timestamp>。
|
|
--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,
|
|
};
|