支持企微通讯录缺失手机号(07-26-internal-ops-recovery-baseline)
这个提交包含在:
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env node
|
||||
require("dotenv/config");
|
||||
|
||||
const fs = require("node:fs");
|
||||
const ExcelJS = require("exceljs");
|
||||
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const {
|
||||
maskEmail,
|
||||
maskExternalUserId,
|
||||
maskPhone,
|
||||
runWecomHrMasterSync,
|
||||
} = require("../services/integrations/wecomHrMaster");
|
||||
|
||||
const APPLY_CONFIRM_ENV = "WECOM_DIRECTORY_EXPORT_APPLY";
|
||||
const REQUIRED_HEADERS = ["姓名", "账号", "部门", "企业邮箱"];
|
||||
const SAFE_EMPTY_PHONE = null;
|
||||
|
||||
class DirectoryExportError extends Error {
|
||||
constructor(code, details = {}) {
|
||||
super(code);
|
||||
this.name = "DirectoryExportError";
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`Usage:
|
||||
node src/scripts/import-wecom-directory-export.js [--input=-] [--dry-run] [--format=json]
|
||||
WECOM_DIRECTORY_EXPORT_APPLY=1 node src/scripts/import-wecom-directory-export.js --input=- --apply --actor-employee-no=<no>
|
||||
|
||||
Reads an Enterprise WeChat directory XLSX from stdin. Dry-run is the default.
|
||||
Reports contain counts and categories only; personal field values are never printed.
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
input: "-",
|
||||
mode: "dry_run",
|
||||
format: "text",
|
||||
actorEmployeeNo: "",
|
||||
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 === "--dry-run") options.mode = "dry_run";
|
||||
else if (arg === "--apply") options.mode = "apply";
|
||||
else if (arg === "--input") options.input = argv[++index] ?? "";
|
||||
else if (arg.startsWith("--input=")) options.input = arg.slice("--input=".length);
|
||||
else if (arg === "--format") options.format = argv[++index] ?? "";
|
||||
else if (arg.startsWith("--format=")) options.format = arg.slice("--format=".length);
|
||||
else if (arg === "--actor-employee-no") options.actorEmployeeNo = argv[++index] ?? "";
|
||||
else if (arg.startsWith("--actor-employee-no=")) {
|
||||
options.actorEmployeeNo = arg.slice("--actor-employee-no=".length);
|
||||
} else throw new DirectoryExportError("unsupported_argument");
|
||||
}
|
||||
if (options.input !== "-") throw new DirectoryExportError("stdin_required");
|
||||
if (!new Set(["text", "json"]).has(options.format)) throw new DirectoryExportError("invalid_format");
|
||||
if (options.mode === "apply") {
|
||||
if (process.env[APPLY_CONFIRM_ENV] !== "1") throw new DirectoryExportError("apply_confirmation_required");
|
||||
if (!/^[A-Za-z0-9_-]{1,80}$/.test(options.actorEmployeeNo)) {
|
||||
throw new DirectoryExportError("actor_required");
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function cellToString(value) {
|
||||
if (value == null) return "";
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||
if (typeof value === "object") {
|
||||
if (Array.isArray(value.richText)) return value.richText.map((item) => item.text ?? "").join("").trim();
|
||||
if (value.text != null) return String(value.text).trim();
|
||||
if (value.result != null) return cellToString(value.result);
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function findHeaderRow(worksheet) {
|
||||
const maximum = Math.min(50, worksheet.rowCount);
|
||||
for (let rowNumber = 1; rowNumber <= maximum; rowNumber += 1) {
|
||||
const labels = worksheet.getRow(rowNumber).values.slice(1).map(cellToString);
|
||||
if (REQUIRED_HEADERS.every((header) => labels.includes(header))) {
|
||||
return { rowNumber, labels };
|
||||
}
|
||||
}
|
||||
throw new DirectoryExportError("required_headers_missing", { missingHeaderCount: REQUIRED_HEADERS.length });
|
||||
}
|
||||
|
||||
function normalizeEmail(value) {
|
||||
const raw = cellToString(value).toLowerCase();
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw) ? raw : "";
|
||||
}
|
||||
|
||||
function normalizePhone(value) {
|
||||
const raw = cellToString(value);
|
||||
if (!raw) return { value: SAFE_EMPTY_PHONE, invalid: false };
|
||||
const normalized = raw.replace(/[\s-]/g, "");
|
||||
if (!/^\+?\d{6,30}$/.test(normalized)) {
|
||||
return { value: SAFE_EMPTY_PHONE, invalid: true };
|
||||
}
|
||||
return { value: normalized, invalid: false };
|
||||
}
|
||||
|
||||
function buildProfile(data) {
|
||||
const externalUserId = cellToString(data["账号"]).slice(0, 128);
|
||||
const name = cellToString(data["姓名"]).slice(0, 100);
|
||||
const phone = normalizePhone(data["手机"]);
|
||||
const email = normalizeEmail(data["企业邮箱"]);
|
||||
const departmentName = cellToString(data["部门"]).slice(0, 100);
|
||||
const title = cellToString(data["职务"]).slice(0, 100) || "员工";
|
||||
const activation = cellToString(data["激活状态"]);
|
||||
const disabled = cellToString(data["禁用状态"]);
|
||||
const active = (!activation || activation === "已激活")
|
||||
&& (!disabled || new Set(["否", "未禁用", "正常", "0"]).has(disabled));
|
||||
const missingRequiredFields = [];
|
||||
for (const [field, value] of [
|
||||
["externalUserId", externalUserId],
|
||||
["name", name],
|
||||
["email", email],
|
||||
["department", departmentName],
|
||||
]) {
|
||||
if (!value) missingRequiredFields.push(field);
|
||||
}
|
||||
if (phone.invalid) missingRequiredFields.push("phone_invalid");
|
||||
return {
|
||||
externalUserId,
|
||||
name,
|
||||
phone: phone.value,
|
||||
email,
|
||||
title,
|
||||
departmentExternalId: departmentName,
|
||||
departmentName,
|
||||
managerExternalUserId: "",
|
||||
hireDate: null,
|
||||
sourceStatus: active ? "active" : "inactive",
|
||||
missingRequiredFields,
|
||||
sourceSummary: {
|
||||
externalUserIdPrefix: maskExternalUserId(externalUserId),
|
||||
phoneMasked: maskPhone(phone.value),
|
||||
emailMasked: maskEmail(email),
|
||||
hasPhone: Boolean(phone.value),
|
||||
hasEmail: Boolean(email),
|
||||
hasDepartment: Boolean(departmentName),
|
||||
sourceStatus: active ? "active" : "inactive",
|
||||
sourceKind: "wecom_directory_export",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countDuplicateValues(profiles, key) {
|
||||
const counts = new Map();
|
||||
for (const profile of profiles) {
|
||||
const value = String(profile[key] ?? "").trim().toLowerCase();
|
||||
if (!value) continue;
|
||||
counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.values()].filter((count) => count > 1).length;
|
||||
}
|
||||
|
||||
async function parseDirectoryExport(buffer) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
const worksheet = workbook.worksheets[0];
|
||||
if (!worksheet) throw new DirectoryExportError("worksheet_missing");
|
||||
const header = findHeaderRow(worksheet);
|
||||
const profiles = [];
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber <= header.rowNumber) return;
|
||||
const data = {};
|
||||
header.labels.forEach((label, index) => {
|
||||
if (label) data[label] = cellToString(row.getCell(index + 1).value);
|
||||
});
|
||||
if (Object.values(data).some(Boolean)) profiles.push(buildProfile(data));
|
||||
});
|
||||
if (!profiles.length) throw new DirectoryExportError("member_rows_missing");
|
||||
|
||||
const duplicateCounts = {
|
||||
account: countDuplicateValues(profiles, "externalUserId"),
|
||||
phone: countDuplicateValues(profiles, "phone"),
|
||||
email: countDuplicateValues(profiles, "email"),
|
||||
};
|
||||
if (Object.values(duplicateCounts).some((count) => count > 0)) {
|
||||
throw new DirectoryExportError("duplicate_values", duplicateCounts);
|
||||
}
|
||||
return {
|
||||
profiles,
|
||||
sourceSummary: {
|
||||
memberCount: profiles.length,
|
||||
activeCount: profiles.filter((profile) => profile.sourceStatus === "active").length,
|
||||
phonePresentCount: profiles.filter((profile) => Boolean(profile.phone)).length,
|
||||
phoneMissingCount: profiles.filter((profile) => !profile.phone).length,
|
||||
emailPresentCount: profiles.filter((profile) => Boolean(profile.email)).length,
|
||||
incompleteCount: profiles.filter((profile) => profile.missingRequiredFields.length > 0).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveActorId(prisma, employeeNo) {
|
||||
if (!employeeNo) return null;
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { employee_no: employeeNo },
|
||||
select: { id: true, status: true, archived_at: true },
|
||||
});
|
||||
if (!employee || employee.status !== "active" || employee.archived_at) {
|
||||
throw new DirectoryExportError("actor_unavailable");
|
||||
}
|
||||
return employee.id;
|
||||
}
|
||||
|
||||
function buildSafeReport(options, parsed, syncRun) {
|
||||
const summary = syncRun?.summary ?? {};
|
||||
const blocked = Number(summary.failed ?? 0) > 0
|
||||
|| Number(summary.conflicts ?? 0) > 0
|
||||
|| Number(summary.skipped ?? 0) > 0
|
||||
|| parsed.sourceSummary.incompleteCount > 0;
|
||||
return {
|
||||
issueId: "WECOM_DIRECTORY_EXPORT",
|
||||
status: blocked
|
||||
? "BLOCKED_DIRECTORY_EXPORT"
|
||||
: options.mode === "apply"
|
||||
? "PASS_DIRECTORY_EXPORT_APPLY"
|
||||
: "PASS_DIRECTORY_EXPORT_DRY_RUN",
|
||||
mode: options.mode,
|
||||
source: parsed.sourceSummary,
|
||||
sync: {
|
||||
created: Number(summary.created ?? 0),
|
||||
updated: Number(summary.updated ?? 0),
|
||||
noop: Number(summary.noop ?? 0),
|
||||
skipped: Number(summary.skipped ?? 0),
|
||||
conflicts: Number(summary.conflicts ?? 0),
|
||||
failed: Number(summary.failed ?? 0),
|
||||
planned: Number(summary.planned ?? 0),
|
||||
success: Number(summary.success ?? 0),
|
||||
},
|
||||
privacy: "counts/categories only; no names, accounts, phones, emails, departments, paths, or raw rows",
|
||||
};
|
||||
}
|
||||
|
||||
function printReport(report, format) {
|
||||
if (format === "json") {
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(`[wecom-directory-export] status=${report.status} mode=${report.mode}\n`);
|
||||
process.stdout.write(`[wecom-directory-export] members=${report.source.memberCount} phonePresent=${report.source.phonePresentCount} phoneMissing=${report.source.phoneMissingCount} incomplete=${report.source.incompleteCount}\n`);
|
||||
process.stdout.write(`[wecom-directory-export] planned=${report.sync.planned} conflicts=${report.sync.conflicts} skipped=${report.sync.skipped} failed=${report.sync.failed}\n`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let resources = null;
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const parsed = await parseDirectoryExport(await fs.promises.readFile(0));
|
||||
resources = createPrismaClient();
|
||||
const actorId = await resolveActorId(resources.prisma, options.actorEmployeeNo);
|
||||
const syncRun = await runWecomHrMasterSync({
|
||||
prisma: resources.prisma,
|
||||
config: null,
|
||||
actorId,
|
||||
mode: options.mode,
|
||||
limit: parsed.profiles.length,
|
||||
sourceProfiles: parsed.profiles,
|
||||
sourceLabel: "manual_export",
|
||||
});
|
||||
const report = buildSafeReport(options, parsed, syncRun);
|
||||
printReport(report, options.format);
|
||||
if (report.status.startsWith("BLOCKED")) process.exitCode = 2;
|
||||
} catch (error) {
|
||||
const code = error instanceof DirectoryExportError ? error.code : "directory_export_failed";
|
||||
process.stderr.write(`[wecom-directory-export][ERROR] ${code}\n`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await resources?.prisma?.$disconnect?.().catch(() => {});
|
||||
await resources?.pool?.end?.().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = {
|
||||
DirectoryExportError,
|
||||
buildProfile,
|
||||
buildSafeReport,
|
||||
countDuplicateValues,
|
||||
findHeaderRow,
|
||||
parseArgs,
|
||||
parseDirectoryExport,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户