支持企微通讯录缺失手机号(07-26-internal-ops-recovery-baseline)
这个提交包含在:
@@ -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",
|
||||
"import:wecom-directory-export": "node src/scripts/import-wecom-directory-export.js",
|
||||
"repair:oa-only-smoke": "node src/scripts/repair-oa-only-smoke-accounts.js",
|
||||
"check:wecom-approval-fixtures": "node src/scripts/check-wecom-approval-fixtures.js",
|
||||
"check:wecom-approval-callback-poc": "node src/scripts/check-wecom-approval-callback-poc.js",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Enterprise WeChat directory exports may omit a member phone number.
|
||||
-- PostgreSQL unique constraints continue to reject duplicate non-null phones
|
||||
-- while allowing multiple employees to remain temporarily unfilled.
|
||||
ALTER TABLE "employees"
|
||||
ALTER COLUMN "phone" DROP NOT NULL;
|
||||
@@ -578,7 +578,7 @@ model Employee {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_no String @unique @default(dbgenerated("generate_employee_number()"))
|
||||
name String
|
||||
phone String @unique
|
||||
phone String? @unique
|
||||
email String @unique
|
||||
department_id String @db.Uuid
|
||||
legal_entity_id String? @db.Uuid
|
||||
|
||||
+19
-14
@@ -58,10 +58,15 @@ const normalizedEmailSchema = z
|
||||
.max(200)
|
||||
.transform((value) => value.toLowerCase());
|
||||
|
||||
const nullablePhoneSchema = z.preprocess(
|
||||
(value) => (typeof value === "string" && value.trim() === "" ? null : value),
|
||||
z.string().trim().min(6).max(30).nullable().optional(),
|
||||
);
|
||||
|
||||
const createBodySchema = z.object({
|
||||
employeeNo: z.string().trim().min(1).max(50).optional(),
|
||||
name: z.string().min(1).max(100),
|
||||
phone: z.string().min(6).max(30),
|
||||
phone: nullablePhoneSchema,
|
||||
email: normalizedEmailSchema,
|
||||
departmentId: z.string().uuid().optional(),
|
||||
title: z.string().min(1).max(100).optional(),
|
||||
@@ -74,7 +79,7 @@ const createBodySchema = z.object({
|
||||
|
||||
const updateBodySchema = z.object({
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
phone: z.string().trim().min(6).max(30).optional(),
|
||||
phone: nullablePhoneSchema,
|
||||
email: normalizedEmailSchema.optional(),
|
||||
departmentId: z.string().uuid().optional(),
|
||||
title: z.string().trim().min(1).max(100).optional(),
|
||||
@@ -98,8 +103,8 @@ const sensitiveBodySchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "phone", "email", "departmentName"];
|
||||
const USER_IMPORT_OPTIONAL_HEADERS = ["title", "directManagerEmployeeNo", "roles", "hireDate"];
|
||||
const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "email", "departmentName"];
|
||||
const USER_IMPORT_OPTIONAL_HEADERS = ["phone", "title", "directManagerEmployeeNo", "roles", "hireDate"];
|
||||
const USER_IMPORT_ALLOWED_EXTENSIONS = new Set([".csv", ".xlsx"]);
|
||||
const USER_IMPORT_ALLOWED_MIME_TYPES = new Set([
|
||||
"text/csv",
|
||||
@@ -773,11 +778,9 @@ function validateImportRowFields(row) {
|
||||
pushImportRowError(row, "name", "too_long");
|
||||
}
|
||||
|
||||
if (!normalized.phone) {
|
||||
pushImportRowError(row, "phone", "required");
|
||||
} else if (normalized.phone.length < 6) {
|
||||
if (normalized.phone && normalized.phone.length < 6) {
|
||||
pushImportRowError(row, "phone", "too_short");
|
||||
} else if (normalized.phone.length > 30) {
|
||||
} else if (normalized.phone && normalized.phone.length > 30) {
|
||||
pushImportRowError(row, "phone", "too_long");
|
||||
}
|
||||
|
||||
@@ -834,7 +837,7 @@ function createDepartmentResolutionMap(departments) {
|
||||
function createEmployeeLookupMaps(employees) {
|
||||
return {
|
||||
byEmployeeNo: new Map(employees.map((item) => [item.employee_no, item])),
|
||||
byPhone: new Map(employees.map((item) => [item.phone, item])),
|
||||
byPhone: new Map(employees.filter((item) => item.phone).map((item) => [item.phone, item])),
|
||||
byEmail: new Map(employees.map((item) => [item.email, item])),
|
||||
};
|
||||
}
|
||||
@@ -912,7 +915,9 @@ async function buildUserImportPreview(prisma, rawRows, options = {}) {
|
||||
pushImportRowError(row, "employeeNo", "duplicate_existing");
|
||||
}
|
||||
|
||||
const phoneConflict = existingLookup.byPhone.get(row.normalized.phone);
|
||||
const phoneConflict = row.normalized.phone
|
||||
? existingLookup.byPhone.get(row.normalized.phone)
|
||||
: null;
|
||||
if (phoneConflict) {
|
||||
pushImportRowError(row, "phone", "duplicate_existing");
|
||||
}
|
||||
@@ -1012,7 +1017,7 @@ async function createSystemUser(tx, options) {
|
||||
? { employee_no: options.employeeNo.trim() }
|
||||
: {}),
|
||||
name: options.name,
|
||||
phone: options.phone,
|
||||
phone: options.phone || null,
|
||||
email: options.email.trim().toLowerCase(),
|
||||
department_id: options.departmentId,
|
||||
title: options.title ?? "员工",
|
||||
@@ -1573,7 +1578,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
const employee = await createSystemUser(tx, {
|
||||
employeeNo: row.normalized.employeeNo,
|
||||
name: row.normalized.name,
|
||||
phone: row.normalized.phone,
|
||||
phone: row.normalized.phone || null,
|
||||
email: row.normalized.email,
|
||||
departmentId: row.resolvedDepartmentId,
|
||||
title: row.normalized.title,
|
||||
@@ -1742,7 +1747,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
if (typeof body.name === "string" && body.name !== current.name) {
|
||||
changes.name = { before: current.name, after: body.name };
|
||||
}
|
||||
if (typeof body.phone === "string" && body.phone !== current.phone) {
|
||||
if (Object.prototype.hasOwnProperty.call(body, "phone") && (body.phone ?? null) !== current.phone) {
|
||||
changes.phone = { before: current.phone, after: body.phone };
|
||||
}
|
||||
if (typeof body.email === "string" && body.email !== current.email) {
|
||||
@@ -1768,7 +1773,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(typeof body.name === "string" ? { name: body.name } : {}),
|
||||
...(typeof body.phone === "string" ? { phone: body.phone } : {}),
|
||||
...(Object.prototype.hasOwnProperty.call(body, "phone") ? { phone: body.phone ?? null } : {}),
|
||||
...(typeof body.email === "string" ? { email: body.email } : {}),
|
||||
...(typeof body.departmentId === "string" ? { department_id: body.departmentId } : {}),
|
||||
...(typeof body.title === "string" ? { title: body.title } : {}),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -468,7 +468,7 @@ function normalizeWecomHrProfile({ contact = {}, staff = {}, departmentMap = new
|
||||
};
|
||||
|
||||
profile.missingRequiredFields = [];
|
||||
for (const field of ["externalUserId", "name", "phone", "email"]) {
|
||||
for (const field of ["externalUserId", "name", "email"]) {
|
||||
if (!isMeaningfulString(profile[field])) profile.missingRequiredFields.push(field);
|
||||
}
|
||||
if (!isMeaningfulString(profile.departmentExternalId) && !isMeaningfulString(profile.departmentName)) {
|
||||
@@ -721,18 +721,21 @@ async function detectUniqueConflicts(prisma, employeeId, profile) {
|
||||
|
||||
function buildEmployeeChanges(employee, profile, department) {
|
||||
if (!employee) {
|
||||
return {
|
||||
const changes = {
|
||||
name: buildChange("name", null, profile.name),
|
||||
phone: buildChange("phone", null, profile.phone),
|
||||
email: buildChange("email", null, profile.email),
|
||||
department: buildChange("department", null, department.name),
|
||||
title: buildChange("title", null, profile.title),
|
||||
};
|
||||
if (profile.phone) changes.phone = buildChange("phone", null, profile.phone);
|
||||
return changes;
|
||||
}
|
||||
|
||||
const changes = {};
|
||||
if (employee.name !== profile.name) changes.name = buildChange("name", employee.name, profile.name);
|
||||
if (employee.phone !== profile.phone) changes.phone = buildChange("phone", employee.phone, profile.phone);
|
||||
if (profile.phone && employee.phone !== profile.phone) {
|
||||
changes.phone = buildChange("phone", employee.phone, profile.phone);
|
||||
}
|
||||
if (employee.email !== profile.email) changes.email = buildChange("email", employee.email, profile.email);
|
||||
if (department.id && employee.department_id !== department.id) {
|
||||
changes.department = buildChange("department", employee.department_id, department.name);
|
||||
@@ -854,7 +857,7 @@ async function processProfile(prisma, runId, profile, context) {
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: profile.name,
|
||||
phone: profile.phone,
|
||||
...(profile.phone ? { phone: profile.phone } : {}),
|
||||
email: profile.email,
|
||||
department_id: department.id,
|
||||
title: profile.title,
|
||||
@@ -866,7 +869,7 @@ async function processProfile(prisma, runId, profile, context) {
|
||||
: await prisma.employee.create({
|
||||
data: {
|
||||
name: profile.name,
|
||||
phone: profile.phone,
|
||||
phone: profile.phone || null,
|
||||
email: profile.email,
|
||||
department_id: department.id,
|
||||
title: profile.title,
|
||||
@@ -1077,6 +1080,8 @@ async function runWecomHrMasterSync({
|
||||
userids: rawUserids,
|
||||
limit: rawLimit,
|
||||
fetchImpl = fetch,
|
||||
sourceProfiles = null,
|
||||
sourceLabel = "manual_export",
|
||||
}) {
|
||||
const mode = normalizeMode(rawMode);
|
||||
const userids = normalizeUserids(rawUserids);
|
||||
@@ -1095,12 +1100,17 @@ async function runWecomHrMasterSync({
|
||||
|
||||
let source = null;
|
||||
try {
|
||||
source = await fetchWecomHrProfiles({
|
||||
config,
|
||||
userids,
|
||||
limit,
|
||||
fetchImpl,
|
||||
});
|
||||
source = Array.isArray(sourceProfiles)
|
||||
? {
|
||||
profiles: sourceProfiles.slice(0, limit),
|
||||
tokenSource: { offline: toSafeString(sourceLabel, 40) || "manual_export" },
|
||||
}
|
||||
: await fetchWecomHrProfiles({
|
||||
config,
|
||||
userids,
|
||||
limit,
|
||||
fetchImpl,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error instanceof WecomHrMasterSyncError && error.code === "wecom_token_unavailable"
|
||||
? "skipped"
|
||||
|
||||
@@ -115,7 +115,7 @@ async function fetchWecomOAuthOnboardingProfile({
|
||||
};
|
||||
|
||||
const missing = [];
|
||||
for (const field of ["name", "phone", "email", "departmentExternalId", "departmentName"]) {
|
||||
for (const field of ["name", "email", "departmentExternalId", "departmentName"]) {
|
||||
if (!profile[field]) missing.push(field);
|
||||
}
|
||||
if (missing.length) {
|
||||
@@ -186,7 +186,12 @@ async function resolveEmployee(tx, profile) {
|
||||
},
|
||||
});
|
||||
const contactMatches = await tx.employee.findMany({
|
||||
where: { OR: [{ phone: profile.phone }, { email: profile.email }] },
|
||||
where: {
|
||||
OR: [
|
||||
...(profile.phone ? [{ phone: profile.phone }] : []),
|
||||
{ email: profile.email },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
@@ -229,7 +234,7 @@ async function provisionWecomOAuthEmployee({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: profile.name,
|
||||
phone: profile.phone,
|
||||
...(profile.phone ? { phone: profile.phone } : {}),
|
||||
email: profile.email,
|
||||
department_id: departmentId,
|
||||
title: profile.title,
|
||||
@@ -241,7 +246,7 @@ async function provisionWecomOAuthEmployee({
|
||||
: await tx.employee.create({
|
||||
data: {
|
||||
name: profile.name,
|
||||
phone: profile.phone,
|
||||
phone: profile.phone || null,
|
||||
email: profile.email,
|
||||
department_id: departmentId,
|
||||
title: profile.title,
|
||||
@@ -294,7 +299,7 @@ async function provisionWecomOAuthEmployee({
|
||||
extra: {
|
||||
created: !existing,
|
||||
source: "wecom_oauth",
|
||||
fieldPresence: { phone: true, email: true },
|
||||
fieldPresence: { phone: Boolean(profile.phone), email: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -219,6 +219,33 @@ describe("users import routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("accepts an import without a phone header and persists a missing phone as null", async () => {
|
||||
const { app, employeeCreate } = await buildApp({
|
||||
departments: [{ id: "dept-1", name: "行政部", archived_at: null }],
|
||||
});
|
||||
const multipartRequest = buildMultipartRequest({
|
||||
filename: "users.csv",
|
||||
contentType: "text/csv",
|
||||
content: [
|
||||
"employeeNo,name,email,departmentName,title,roles,hireDate",
|
||||
"E006,NoPhone,[email protected],行政部,员工,employee,2026-07-27",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/users/import/apply",
|
||||
...multipartRequest,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(employeeCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ phone: null }),
|
||||
select: expect.any(Object),
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects dry-run uploads with unsupported mime type", async () => {
|
||||
const { app } = await buildApp();
|
||||
const multipartRequest = buildMultipartRequest({
|
||||
|
||||
@@ -62,7 +62,27 @@ async function buildApp({
|
||||
roles: [{ role_code: "employee" }],
|
||||
},
|
||||
);
|
||||
const employeeUpdate = vi.fn();
|
||||
const employeeUpdate = vi.fn(async ({ data }) => ({
|
||||
...(currentEmployee ?? {
|
||||
id: USER_ID,
|
||||
employee_no: "HYD000321",
|
||||
name: "Alice",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
status: "active",
|
||||
department_id: DEPARTMENT_ID,
|
||||
title: "员工",
|
||||
direct_manager_id: null,
|
||||
hire_date: new Date("2026-05-01T00:00:00.000Z"),
|
||||
archived_at: null,
|
||||
created_at: new Date("2026-05-01T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-05-01T00:00:00.000Z"),
|
||||
roles: [{ role_code: "employee" }],
|
||||
wecom_identity_binding: null,
|
||||
wecom_directory_sync_runs: [],
|
||||
}),
|
||||
...data,
|
||||
}));
|
||||
const employeeRoleCreateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const employeeRoleFindMany = vi.fn().mockResolvedValue(currentRoles.map((role_code) => ({ role_code })));
|
||||
const employeeRoleDeleteMany = vi.fn().mockResolvedValue({ count: currentRoles.length });
|
||||
@@ -139,10 +159,6 @@ async function buildApp({
|
||||
version: 1,
|
||||
}),
|
||||
},
|
||||
wecomDirectorySyncRun: {
|
||||
create: wecomSyncRunCreate,
|
||||
},
|
||||
wecomIdentityBinding: {},
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
@@ -243,6 +259,45 @@ describe("usersRoutes write flows", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("creates a user with a null phone when the field is omitted", async () => {
|
||||
const { app, employeeCreate } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/users",
|
||||
payload: {
|
||||
name: "暂缺手机号成员",
|
||||
email: "[email protected]",
|
||||
roles: ["employee"],
|
||||
syncWecomDirectory: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(employeeCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ phone: null }),
|
||||
}));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("allows an administrator to explicitly clear an existing phone to null", async () => {
|
||||
const { app, employeeUpdate } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/users/${USER_ID}`,
|
||||
payload: { phone: null },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(employeeUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ phone: null }),
|
||||
}));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects employeeNo changes on patch", async () => {
|
||||
const { app, employeeFindUnique, employeeUpdate } = await buildApp();
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
const ExcelJS = require("exceljs");
|
||||
|
||||
const {
|
||||
DirectoryExportError,
|
||||
buildSafeReport,
|
||||
parseArgs,
|
||||
parseDirectoryExport,
|
||||
} = require("../../../src/scripts/import-wecom-directory-export");
|
||||
|
||||
async function buildDirectoryWorkbook(rows) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet("通讯录");
|
||||
worksheet.addRow(["填写须知:本行为企微导出说明"]);
|
||||
worksheet.addRow([]);
|
||||
worksheet.addRow([
|
||||
"姓名", "账号", "别名", "职务", "部门", "性别", "手机", "座机",
|
||||
"邮箱", "地址", "企业邮箱", "视频号", "激活状态", "禁用状态", "微信插件",
|
||||
]);
|
||||
for (const row of rows) {
|
||||
worksheet.addRow([
|
||||
row.name,
|
||||
row.account,
|
||||
"",
|
||||
row.title ?? "员工",
|
||||
row.department ?? "总部",
|
||||
"",
|
||||
row.phone ?? "",
|
||||
"",
|
||||
row.email ?? "",
|
||||
"",
|
||||
row.businessEmail,
|
||||
"",
|
||||
"已激活",
|
||||
"",
|
||||
"未关注",
|
||||
]);
|
||||
}
|
||||
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
}
|
||||
|
||||
describe("WeCom directory export importer", () => {
|
||||
it("requires stdin, the apply environment gate, and an actor for apply", () => {
|
||||
const previous = process.env.WECOM_DIRECTORY_EXPORT_APPLY;
|
||||
delete process.env.WECOM_DIRECTORY_EXPORT_APPLY;
|
||||
try {
|
||||
expect(() => parseArgs(["--input=directory.xlsx"])).toThrowError(
|
||||
expect.objectContaining({ code: "stdin_required" }),
|
||||
);
|
||||
expect(() => parseArgs(["--apply", "--actor-employee-no=HYD000001"])).toThrowError(
|
||||
expect.objectContaining({ code: "apply_confirmation_required" }),
|
||||
);
|
||||
process.env.WECOM_DIRECTORY_EXPORT_APPLY = "1";
|
||||
expect(() => parseArgs(["--apply"])).toThrowError(
|
||||
expect.objectContaining({ code: "actor_required" }),
|
||||
);
|
||||
expect(parseArgs(["--apply", "--actor-employee-no=HYD000001"])).toEqual(
|
||||
expect.objectContaining({ input: "-", mode: "apply", actorEmployeeNo: "HYD000001" }),
|
||||
);
|
||||
} finally {
|
||||
if (previous == null) delete process.env.WECOM_DIRECTORY_EXPORT_APPLY;
|
||||
else process.env.WECOM_DIRECTORY_EXPORT_APPLY = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("finds the official Chinese header after instruction rows and accepts one missing phone", async () => {
|
||||
const rows = Array.from({ length: 6 }, (_, index) => ({
|
||||
name: `合成员${index + 1}`,
|
||||
account: `member-${index + 1}`,
|
||||
phone: index === 5 ? "" : `1990000000${index + 1}`,
|
||||
businessEmail: `member-${index + 1}@example.test`,
|
||||
}));
|
||||
const parsed = await parseDirectoryExport(await buildDirectoryWorkbook(rows));
|
||||
|
||||
expect(parsed.sourceSummary).toEqual({
|
||||
memberCount: 6,
|
||||
activeCount: 6,
|
||||
phonePresentCount: 5,
|
||||
phoneMissingCount: 1,
|
||||
emailPresentCount: 6,
|
||||
incompleteCount: 0,
|
||||
});
|
||||
expect(parsed.profiles[5].phone).toBeNull();
|
||||
expect(parsed.profiles[5].missingRequiredFields).toEqual([]);
|
||||
|
||||
const report = buildSafeReport(
|
||||
{ mode: "dry_run" },
|
||||
parsed,
|
||||
{ summary: { created: 5, updated: 1, planned: 6 } },
|
||||
);
|
||||
const serialized = JSON.stringify(report);
|
||||
expect(report.status).toBe("PASS_DIRECTORY_EXPORT_DRY_RUN");
|
||||
for (const row of rows) {
|
||||
expect(serialized).not.toContain(row.name);
|
||||
expect(serialized).not.toContain(row.account);
|
||||
expect(serialized).not.toContain(row.businessEmail);
|
||||
if (row.phone) expect(serialized).not.toContain(row.phone);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["account", { account: "member-1", phone: "19900000002", businessEmail: "[email protected]" }],
|
||||
["phone", { account: "member-2", phone: "19900000001", businessEmail: "[email protected]" }],
|
||||
["email", { account: "member-2", phone: "19900000002", businessEmail: "[email protected]" }],
|
||||
])("rejects duplicate %s values without returning the duplicated value", async (_field, duplicateRow) => {
|
||||
const buffer = await buildDirectoryWorkbook([
|
||||
{
|
||||
name: "合成员一",
|
||||
account: "member-1",
|
||||
phone: "19900000001",
|
||||
businessEmail: "[email protected]",
|
||||
},
|
||||
{ name: "合成员二", ...duplicateRow },
|
||||
]);
|
||||
|
||||
let caught;
|
||||
try {
|
||||
await parseDirectoryExport(buffer);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(DirectoryExportError);
|
||||
expect(caught.code).toBe("duplicate_values");
|
||||
const serialized = JSON.stringify(caught);
|
||||
expect(serialized).not.toContain("[email protected]");
|
||||
expect(serialized).not.toContain("19900000001");
|
||||
});
|
||||
|
||||
it("requires a valid enterprise email even when a personal email is present", async () => {
|
||||
const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{
|
||||
name: "合成员一",
|
||||
account: "member-1",
|
||||
phone: "19900000001",
|
||||
email: "[email protected]",
|
||||
businessEmail: "",
|
||||
}]));
|
||||
|
||||
expect(parsed.sourceSummary.incompleteCount).toBe(1);
|
||||
expect(parsed.profiles[0].missingRequiredFields).toContain("email");
|
||||
});
|
||||
|
||||
it("blocks a supplied invalid phone instead of treating it as an absent phone", async () => {
|
||||
const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{
|
||||
name: "合成员一",
|
||||
account: "member-1",
|
||||
phone: "not-a-phone",
|
||||
businessEmail: "[email protected]",
|
||||
}]));
|
||||
|
||||
expect(parsed.sourceSummary.phoneMissingCount).toBe(1);
|
||||
expect(parsed.sourceSummary.incompleteCount).toBe(1);
|
||||
expect(parsed.profiles[0].missingRequiredFields).toContain("phone_invalid");
|
||||
});
|
||||
|
||||
it("uses a distinct success status for an apply report", async () => {
|
||||
const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{
|
||||
name: "合成员一",
|
||||
account: "member-1",
|
||||
phone: "",
|
||||
businessEmail: "[email protected]",
|
||||
}]));
|
||||
|
||||
const report = buildSafeReport(
|
||||
{ mode: "apply" },
|
||||
parsed,
|
||||
{ summary: { created: 1, success: 1 } },
|
||||
);
|
||||
expect(report.status).toBe("PASS_DIRECTORY_EXPORT_APPLY");
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,38 @@ describe("wecom HR master sync helpers", () => {
|
||||
expect(profile.missingRequiredFields).toContain("email");
|
||||
});
|
||||
|
||||
it("accepts a profile with no phone and does not plan clearing an existing phone", () => {
|
||||
const profile = normalizeWecomHrProfile({
|
||||
contact: {
|
||||
userid: "missing-phone",
|
||||
name: "缺手机号成员",
|
||||
biz_mail: "[email protected]",
|
||||
department: [1],
|
||||
enable: 1,
|
||||
},
|
||||
staff: {},
|
||||
departmentMap: new Map([["1", { externalId: "1", name: "行政部" }]]),
|
||||
});
|
||||
|
||||
expect(profile.phone).toBe("");
|
||||
expect(profile.missingRequiredFields).toEqual([]);
|
||||
|
||||
const changes = __testables.buildEmployeeChanges(
|
||||
{
|
||||
id: "employee-1",
|
||||
name: profile.name,
|
||||
phone: "19900000009",
|
||||
email: profile.email,
|
||||
department_id: "department-1",
|
||||
title: "员工",
|
||||
wecom_identity_binding: { external_user_id: profile.externalUserId },
|
||||
},
|
||||
profile,
|
||||
{ id: "department-1", name: "行政部" },
|
||||
);
|
||||
expect(changes).not.toHaveProperty("phone");
|
||||
});
|
||||
|
||||
it("marks inactive WeCom members as confirmation-required instead of active", () => {
|
||||
const profile = normalizeWecomHrProfile({
|
||||
contact: {
|
||||
|
||||
@@ -101,6 +101,20 @@ describe("wecom OAuth onboarding service", () => {
|
||||
});
|
||||
expect(profile.email).toBe("[email protected]");
|
||||
|
||||
const profileWithoutPhone = await fetchWecomOAuthOnboardingProfile({
|
||||
accessToken: "agent-token",
|
||||
externalUserId: "member-fields",
|
||||
userTicket: "ticket",
|
||||
fetchImpl: buildFetch({
|
||||
errcode: 0,
|
||||
userid: "member-fields",
|
||||
mobile: "",
|
||||
biz_mail: "[email protected]",
|
||||
}),
|
||||
});
|
||||
expect(profileWithoutPhone.phone).toBe("");
|
||||
expect(profileWithoutPhone.email).toBe("[email protected]");
|
||||
|
||||
await expect(fetchWecomOAuthOnboardingProfile({
|
||||
accessToken: "agent-token",
|
||||
externalUserId: "member-fields",
|
||||
@@ -238,6 +252,52 @@ describe("wecom OAuth onboarding service", () => {
|
||||
expect(persisted).not.toContain("[email protected]");
|
||||
});
|
||||
|
||||
it("creates a member with a null phone when the authorized profile has no phone", async () => {
|
||||
const tx = {
|
||||
wecomIdentityBinding: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
upsert: vi.fn().mockResolvedValue({ id: "binding-1" }),
|
||||
},
|
||||
employee: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue({ id: "employee-1", token_version: 0 }),
|
||||
},
|
||||
integrationBinding: {
|
||||
findFirst: vi.fn().mockResolvedValue({ local_id: "department-1" }),
|
||||
},
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "department-1", archived_at: null }),
|
||||
},
|
||||
employeeRole: { create: vi.fn().mockResolvedValue({}) },
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
const prisma = { $transaction: vi.fn(async (callback) => callback(tx)) };
|
||||
|
||||
await provisionWecomOAuthEmployee({
|
||||
prisma,
|
||||
request: buildRequest(),
|
||||
profile: {
|
||||
externalUserId: "member-no-phone",
|
||||
name: "缺手机号成员",
|
||||
phone: "",
|
||||
email: "[email protected]",
|
||||
departmentExternalId: "1",
|
||||
departmentName: "总部",
|
||||
title: "员工",
|
||||
},
|
||||
});
|
||||
|
||||
expect(tx.employee.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ phone: null }),
|
||||
select: expect.any(Object),
|
||||
});
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
extra: expect.objectContaining({ fieldPresence: { phone: false, email: true } }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects contact data that resolves to multiple OA employees", async () => {
|
||||
const tx = {
|
||||
wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
|
||||
在新工单中引用
屏蔽一个用户