WOUAFIX 收口 OA 与企微用户一致性差异
这个提交包含在:
@@ -29,6 +29,7 @@
|
||||
"audit:smoke-data": "node src/scripts/audit-smoke-data.js",
|
||||
"audit:org-master-data": "node src/scripts/audit-org-master-data.js",
|
||||
"audit:wecom-oa-users": "node src/scripts/audit-wecom-oa-user-consistency.js",
|
||||
"resolve:wecom-oa-users": "node src/scripts/resolve-wecom-oa-user-consistency.js",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
#!/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]> 限定允许处理的员工号;可重复。
|
||||
--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: [],
|
||||
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 === "--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);
|
||||
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 shouldInclude = (item) => !allowSet.size || allowSet.has(item.employeeNo);
|
||||
const archiveCandidates = buildEmployeeNoActions(
|
||||
findings.oaActiveMissingInWecom,
|
||||
"archive_oa_test_employee",
|
||||
"user_confirmed_oa_active_missing_in_wecom_are_test_data",
|
||||
).filter(shouldInclude);
|
||||
const excludedByAllowlist = allowSet.size
|
||||
? buildEmployeeNoActions(
|
||||
findings.oaActiveMissingInWecom,
|
||||
"excluded_by_allowlist",
|
||||
"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",
|
||||
missingRequiredFields: "accepted_no_write",
|
||||
inactiveWithBinding: inactiveBindingAction,
|
||||
managerSideEffects: options.repairManagerEffects ? "repair_to_active_manager" : "report_only",
|
||||
wecomWriteBehavior: "no_wecom_writes",
|
||||
},
|
||||
archiveOaTestEmployees: archiveCandidates,
|
||||
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,
|
||||
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),
|
||||
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,
|
||||
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),
|
||||
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,
|
||||
};
|
||||
@@ -208,10 +208,23 @@ async function ensureAdminEmployee(prisma, params) {
|
||||
|
||||
const existing = await prisma.employee.findFirst({
|
||||
where: { OR: [{ email: params.email }, { phone: params.phone }] },
|
||||
select: { id: true, email: true, phone: true, status: true },
|
||||
select: { id: true, employee_no: true, email: true, phone: true, status: true, archived_at: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if ((existing.status === "archived" || existing.archived_at) && process.env.TRIALRUN_ADMIN_REACTIVATE_ARCHIVED !== "1") {
|
||||
const fallbackAdmin = await findActiveAdminEmployee(prisma, existing.id);
|
||||
if (fallbackAdmin) {
|
||||
return {
|
||||
employeeId: fallbackAdmin.id,
|
||||
created: false,
|
||||
password: null,
|
||||
skippedArchived: true,
|
||||
fallbackEmployeeNo: fallbackAdmin.employee_no,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
@@ -278,6 +291,23 @@ async function ensureAdminEmployee(prisma, params) {
|
||||
return { employeeId: employee.id, created: true, password };
|
||||
}
|
||||
|
||||
async function findActiveAdminEmployee(prisma, excludedEmployeeId) {
|
||||
const where = {
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
roles: { some: { role_code: "admin" } },
|
||||
};
|
||||
if (excludedEmployeeId) {
|
||||
where.id = { not: excludedEmployeeId };
|
||||
}
|
||||
|
||||
return prisma.employee.findFirst({
|
||||
where,
|
||||
orderBy: { employee_no: "asc" },
|
||||
select: { id: true, employee_no: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const adminEmail = requiredEnv("TRIALRUN_ADMIN_EMAIL");
|
||||
const adminPhone = requiredEnv("TRIALRUN_ADMIN_PHONE");
|
||||
@@ -326,6 +356,10 @@ async function main() {
|
||||
console.log(`- created: ${result.created}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- force_on flags: ${forceOnFlags.join(", ")}`);
|
||||
if (result.skippedArchived) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- archived trialrun admin: kept archived; active admin fallback: ${result.fallbackEmployeeNo}`);
|
||||
}
|
||||
if (result.password) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- one-time password: ${result.password}`);
|
||||
|
||||
@@ -163,21 +163,43 @@ async function main() {
|
||||
assertEnvValues(runtimeMode);
|
||||
|
||||
const adminEmail = (process.env.TRIALRUN_ADMIN_EMAIL || '[email protected]').trim();
|
||||
const adminEmployee = await prisma.employee.findFirst({
|
||||
where: { email: adminEmail },
|
||||
let adminEmployee = await prisma.employee.findFirst({
|
||||
where: { email: adminEmail, status: 'active', archived_at: null },
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
email: true,
|
||||
status: true,
|
||||
archived_at: true,
|
||||
department_id: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
});
|
||||
if (!adminEmployee) {
|
||||
adminEmployee = await prisma.employee.findFirst({
|
||||
where: {
|
||||
status: 'active',
|
||||
archived_at: null,
|
||||
roles: { some: { role_code: 'admin' } },
|
||||
},
|
||||
orderBy: { employee_no: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
email: true,
|
||||
status: true,
|
||||
archived_at: true,
|
||||
department_id: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
assertCondition(Boolean(adminEmployee), `未找到 trialrun admin 员工:${adminEmail}`);
|
||||
assertCondition(adminEmployee.status === 'active', `trialrun admin 状态异常:${adminEmployee.status}`);
|
||||
console.log(`[PASS] 已找到 active admin 员工:${adminEmployee.email}`);
|
||||
assertCondition(Boolean(adminEmployee), `未找到 active admin 员工:${adminEmail}`);
|
||||
assertCondition(adminEmployee.status === 'active' && !adminEmployee.archived_at, `active admin 状态异常:${adminEmployee.status}`);
|
||||
console.log(`[PASS] 已找到 active admin 员工:${adminEmployee.employee_no}`);
|
||||
|
||||
const workspace = await getWorkspaceSetting(prisma);
|
||||
assertWorkspaceSetting(workspace);
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
const {
|
||||
applyPlanWithClient,
|
||||
assertApplyAllowed,
|
||||
buildDryRunResult,
|
||||
buildFixPlan,
|
||||
normalizeEmployeeNoList,
|
||||
parseArgs,
|
||||
} = require("../../../src/scripts/resolve-wecom-oa-user-consistency");
|
||||
|
||||
function auditReport(overrides = {}) {
|
||||
return {
|
||||
status: "PASS_WITH_FINDINGS",
|
||||
generatedAt: "2026-05-18T14:25:44.000Z",
|
||||
findings: {
|
||||
oaActiveMissingInWecom: [
|
||||
{ employeeNo: "TEST001", status: "active", roles: ["employee"] },
|
||||
{ employeeNo: "TEST002", status: "active", roles: ["employee"] },
|
||||
],
|
||||
missingRequiredFields: [
|
||||
{
|
||||
employee: { employeeNo: "REAL001", status: "active", roles: ["employee"] },
|
||||
wecom: { externalUserIdPrefix: "wecom_re***" },
|
||||
fields: ["email", "phone", "department"],
|
||||
},
|
||||
],
|
||||
inactiveWithBinding: [
|
||||
{ employeeNo: "HRWDTEST-20260517214548", status: "archived", archived: true, roles: ["employee"] },
|
||||
],
|
||||
matchedActive: [],
|
||||
wecomActiveMissingInOa: [],
|
||||
oaActiveMissingBinding: [],
|
||||
statusMismatch: [],
|
||||
matchConflict: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function employeeRow(overrides = {}) {
|
||||
return {
|
||||
id: overrides.id ?? "11111111-1111-4111-8111-111111111111",
|
||||
employee_no: overrides.employeeNo ?? "TEST001",
|
||||
status: overrides.status ?? "active",
|
||||
employment_status: overrides.employmentStatus ?? "active",
|
||||
archived: overrides.archived ?? false,
|
||||
token_version: overrides.tokenVersion ?? 1,
|
||||
roles: overrides.roles ?? ["employee"],
|
||||
has_wecom_binding: overrides.hasWecomBinding ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClient(rows) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async query(sql, params) {
|
||||
const text = String(sql);
|
||||
calls.push({ sql: text, params });
|
||||
if (text.includes("FROM employees e")) {
|
||||
return { rows };
|
||||
}
|
||||
return { rows: [], rowCount: 1 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sqlCalls(client, pattern) {
|
||||
return client.calls.filter((call) => call.sql.includes(pattern));
|
||||
}
|
||||
|
||||
describe("resolve-wecom-oa-user-consistency", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.WOUA_FIX_APPLY;
|
||||
});
|
||||
|
||||
it("builds a default plan that archives missing OA test employees and accepts missing WeCom fields", () => {
|
||||
const plan = buildFixPlan(auditReport());
|
||||
const dryRun = buildDryRunResult(plan);
|
||||
const serialized = JSON.stringify(dryRun);
|
||||
|
||||
expect(plan.archiveOaTestEmployees.map((item) => item.employeeNo)).toEqual(["TEST001", "TEST002"]);
|
||||
expect(plan.acceptedMissingRequiredFields).toEqual([
|
||||
expect.objectContaining({
|
||||
employeeNo: "REAL001",
|
||||
action: "accepted_no_write",
|
||||
fields: ["department", "email", "phone"],
|
||||
}),
|
||||
]);
|
||||
expect(plan.inactiveWithBinding[0]).toEqual(expect.objectContaining({
|
||||
employeeNo: "HRWDTEST-20260517214548",
|
||||
action: "accepted_no_write",
|
||||
}));
|
||||
expect(dryRun.totals.wecomWrites).toBe(0);
|
||||
expect(serialized).not.toContain("13800000001");
|
||||
expect(serialized).not.toContain("[email protected]");
|
||||
expect(serialized).not.toContain("wecom_user_001_very_long");
|
||||
});
|
||||
|
||||
it("supports allowlist narrowing to avoid touching unlisted employees", () => {
|
||||
const plan = buildFixPlan(auditReport(), { allowEmployeeNos: ["TEST002"] });
|
||||
|
||||
expect(plan.archiveOaTestEmployees.map((item) => item.employeeNo)).toEqual(["TEST002"]);
|
||||
expect(plan.excludedByAllowlist.map((item) => item.employeeNo)).toEqual(["TEST001"]);
|
||||
});
|
||||
|
||||
it("requires explicit environment approval for apply mode", () => {
|
||||
expect(() => assertApplyAllowed({ mode: "apply" })).toThrow(/WOUA_FIX_APPLY=1/);
|
||||
process.env.WOUA_FIX_APPLY = "1";
|
||||
expect(() => assertApplyAllowed({ mode: "apply" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("parses mode and repeated employee allowlist arguments", () => {
|
||||
const options = parseArgs([
|
||||
"--report=summary.json",
|
||||
"--apply",
|
||||
"--allow-employee-no=TEST001,TEST002",
|
||||
"--allow-employee-no",
|
||||
"TEST001",
|
||||
"--repair-manager-effects",
|
||||
"--manager-fallback-employee-no=HYD000007",
|
||||
"--format=json",
|
||||
]);
|
||||
|
||||
expect(options.mode).toBe("apply");
|
||||
expect(options.reportPath).toBe("summary.json");
|
||||
expect(options.format).toBe("json");
|
||||
expect(options.allowEmployeeNos).toEqual(["TEST001", "TEST002"]);
|
||||
expect(options.repairManagerEffects).toBe(true);
|
||||
expect(options.managerFallbackEmployeeNo).toBe("HYD000007");
|
||||
});
|
||||
|
||||
it("deduplicates normalized employee numbers", () => {
|
||||
expect(normalizeEmployeeNoList([" TEST001 ", "TEST001", "", "TEST002"])).toEqual(["TEST001", "TEST002"]);
|
||||
});
|
||||
|
||||
it("dry-runs inside a rolled-back transaction and does not update employees", async () => {
|
||||
const plan = buildFixPlan(auditReport(), { allowEmployeeNos: ["TEST001"] });
|
||||
const client = buildClient([employeeRow({ employeeNo: "TEST001" })]);
|
||||
const result = await applyPlanWithClient(client, plan, {
|
||||
mode: "dry_run",
|
||||
now: new Date("2026-05-18T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.status).toBe("DRY_RUN");
|
||||
expect(result.totals.archivedPlanned).toBe(1);
|
||||
expect(sqlCalls(client, "UPDATE employees")).toHaveLength(0);
|
||||
expect(sqlCalls(client, "ROLLBACK")).toHaveLength(1);
|
||||
expect(sqlCalls(client, "COMMIT")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("archives active test employees in apply mode and increments token_version", async () => {
|
||||
const plan = buildFixPlan(auditReport(), { allowEmployeeNos: ["TEST001"] });
|
||||
const client = buildClient([employeeRow({ employeeNo: "TEST001" })]);
|
||||
const result = await applyPlanWithClient(client, plan, {
|
||||
mode: "apply",
|
||||
now: new Date("2026-05-18T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.status).toBe("APPLIED");
|
||||
expect(result.totals.archivedApplied).toBe(1);
|
||||
expect(sqlCalls(client, "UPDATE employees")).toHaveLength(1);
|
||||
expect(sqlCalls(client, "token_version = token_version + 1")).toHaveLength(1);
|
||||
expect(sqlCalls(client, "INSERT INTO employee_lifecycle_events")).toHaveLength(1);
|
||||
expect(sqlCalls(client, "COMMIT")).toHaveLength(1);
|
||||
expect(sqlCalls(client, "ROLLBACK")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps inactive WeCom bindings by default and clears them only with explicit flag", async () => {
|
||||
const plan = buildFixPlan(auditReport(), { clearInactiveBindings: true });
|
||||
const dryClient = buildClient([
|
||||
employeeRow({ employeeNo: "TEST001" }),
|
||||
employeeRow({
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
employeeNo: "HRWDTEST-20260517214548",
|
||||
status: "archived",
|
||||
employmentStatus: "inactive",
|
||||
archived: true,
|
||||
hasWecomBinding: true,
|
||||
}),
|
||||
]);
|
||||
const defaultPlan = buildFixPlan(auditReport());
|
||||
const defaultClient = buildClient([
|
||||
employeeRow({ employeeNo: "TEST001" }),
|
||||
]);
|
||||
|
||||
const defaultResult = await applyPlanWithClient(defaultClient, defaultPlan, {
|
||||
mode: "apply",
|
||||
now: new Date("2026-05-18T00:00:00.000Z"),
|
||||
});
|
||||
const explicitResult = await applyPlanWithClient(dryClient, plan, {
|
||||
mode: "apply",
|
||||
clearInactiveBindings: true,
|
||||
now: new Date("2026-05-18T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(defaultResult.totals.inactiveBindingAccepted).toBe(1);
|
||||
expect(sqlCalls(defaultClient, "DELETE FROM wecom_identity_bindings")).toHaveLength(0);
|
||||
expect(explicitResult.totals.inactiveBindingApplied).toBe(1);
|
||||
expect(sqlCalls(dryClient, "DELETE FROM wecom_identity_bindings")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户