支持企微通讯录缺失手机号(07-26-internal-ops-recovery-baseline)
这个提交包含在:
@@ -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) },
|
||||
|
||||
在新工单中引用
屏蔽一个用户