WOUAFIX 收口 OA 与企微用户一致性差异

这个提交包含在:
2026-05-19 00:35:50 +08:00
父节点 c8884bb372
当前提交 56a1fc3b1d
共修改 19 个文件,包含 1034 行新增和 51 行删除
+1
查看文件
@@ -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,
};
+35 -1
查看文件
@@ -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}`);
+27 -5
查看文件
@@ -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);
});
});