#!/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= 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 readStdinBuffer() { return fs.readFileSync(0); } 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(readStdinBuffer()); 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, readStdinBuffer, };