WHMS 实现企微HR主数据镜像同步

这个提交包含在:
2026-05-18 19:19:07 +08:00
父节点 3a6bfa0eaa
当前提交 27968fba49
共修改 24 个文件,包含 2123 行新增和 39 行删除
+70
查看文件
@@ -33,6 +33,55 @@ export type WecomDirectoryState = {
latestSync: WecomDirectorySyncRun | null;
};
export type WecomHrMasterSyncMode = "dry_run" | "apply";
export type WecomHrMasterSyncRunStatus = "running" | "success" | "partial" | "failed" | "skipped";
export type WecomHrMasterSyncItemStatus = "planned" | "success" | "skipped" | "conflict" | "failed";
export type WecomHrMasterSyncItem = {
id: string;
runId: string;
employeeId: string | null;
externalUserId: string;
action: "create" | "update" | "noop" | "skip" | "conflict" | "failed";
status: WecomHrMasterSyncItemStatus;
errorCode: string | null;
errorMessage: string | null;
retryCount: number;
fieldChanges: Record<string, unknown> | null;
sourceSummary: Record<string, unknown> | null;
createdAt: string | null;
updatedAt: string | null;
};
export type WecomHrMasterSyncRun = {
id: string;
mode: WecomHrMasterSyncMode;
status: WecomHrMasterSyncRunStatus;
tokenSource: Record<string, string> | null;
requestedUserids: string[] | null;
limit: number | null;
summary: {
total?: number;
created?: number;
updated?: number;
noop?: number;
skipped?: number;
conflicts?: number;
failed?: number;
planned?: number;
success?: number;
} | null;
errorCode: string | null;
errorMessage: string | null;
triggeredBy: string | null;
startedAt: string | null;
finishedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
itemCount?: number;
items?: WecomHrMasterSyncItem[];
};
export type EmployeeDepartmentSummary = {
id: string;
name: string;
@@ -457,6 +506,27 @@ export async function retryEmployeeWecomDirectorySync(employeeId: string, operat
});
}
export async function runWecomHrMasterSync(payload: {
mode: WecomHrMasterSyncMode;
userids?: string[];
limit?: number;
}) {
return apiRequestData<WecomHrMasterSyncRun>("/api/employees/wecom-hr-master-sync/runs", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function fetchWecomHrMasterSyncRuns(pageSize = 5) {
return apiRequestData<{ items: WecomHrMasterSyncRun[] }>(
`/api/employees/wecom-hr-master-sync/runs?pageSize=${pageSize}`,
);
}
export async function fetchWecomHrMasterSyncRun(runId: string) {
return apiRequestData<WecomHrMasterSyncRun>(`/api/employees/wecom-hr-master-sync/runs/${runId}`);
}
export async function fetchEmployeeOrganization() {
return apiRequestData<{ items: OrganizationDepartmentSection[] }>("/api/employees/organization");
}
+1
查看文件
@@ -18,6 +18,7 @@ const FEATURE_FLAG_DISABLED_MESSAGES: Record<string, string> = {
attendance_unified_v1_disabled: "企微考勤统一汇总当前未启用,请联系管理员检查环境开关与功能开关配置。",
weekly_report_v1_disabled: "周报模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
employee_lifecycle_v1_disabled: "人员生命周期模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
wecom_hr_master_sync_v1_disabled: "企微 HR 主数据镜像当前未启用,请联系管理员检查环境开关与功能开关配置。",
todo_board_v1_disabled: "待办模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
project_domain_v1_disabled: "项目模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
project_linking_v1_disabled: "项目关联功能当前未启用,请联系管理员检查环境开关与功能开关配置。",
+2
查看文件
@@ -17,6 +17,8 @@ export type FeatureFlagKey =
| "attendance_unified_v1"
| "weekly_report_v1"
| "employee_lifecycle_v1"
| "hr_wecom_directory_sync_v1"
| "wecom_hr_master_sync_v1"
| "todo_board_v1"
| "wecom_entry_v1"
| "project_domain_v1"
+134 -1
查看文件
@@ -8,12 +8,15 @@ import {
batchTransferEmployeesDepartment,
fetchEmployees,
fetchEmployeeOrganization,
fetchWecomHrMasterSyncRuns,
regularizeEmployee,
retryEmployeeWecomDirectorySync,
runWecomHrMasterSync,
startEmployeeOffboarding,
startEmployeeOnboarding,
type EmployeeEmploymentStatus,
type EmployeeSummary,
type WecomHrMasterSyncRun,
type WecomDirectoryState,
type OrganizationTreeNode,
} from "../api/employees";
@@ -90,6 +93,34 @@ function shouldShowWecomRetry(state?: WecomDirectoryState) {
return !state || ["unbound", "sync_failed", "sync_skipped"].includes(state.status);
}
function wecomHrMasterRunStatusLabel(status: WecomHrMasterSyncRun["status"]) {
const map: Record<WecomHrMasterSyncRun["status"], string> = {
running: "执行中",
success: "已完成",
partial: "部分完成",
failed: "失败",
skipped: "已跳过",
};
return map[status] ?? status;
}
function wecomHrMasterModeLabel(mode: WecomHrMasterSyncRun["mode"]) {
return mode === "apply" ? "写入" : "预检";
}
function formatSyncSummary(run?: WecomHrMasterSyncRun | null) {
const summary = run?.summary;
if (!summary) return "暂无统计";
return [
`总数 ${summary.total ?? 0}`,
`新建 ${summary.created ?? 0}`,
`更新 ${summary.updated ?? 0}`,
`跳过 ${summary.skipped ?? 0}`,
`冲突 ${summary.conflicts ?? 0}`,
`失败 ${summary.failed ?? 0}`,
].join(" / ");
}
function collectOrganizationEmployees(nodes: OrganizationTreeNode[], target: Map<string, { id: string; name: string; employeeNo: string; departmentName: string }>) {
for (const node of nodes) {
if (!target.has(node.id)) {
@@ -124,6 +155,7 @@ export function PeoplePage() {
const [managerDialogOpen, setManagerDialogOpen] = useState(false);
const [enableConfirmOpen, setEnableConfirmOpen] = useState(false);
const [disableConfirmOpen, setDisableConfirmOpen] = useState(false);
const [wecomHrApplyConfirmOpen, setWecomHrApplyConfirmOpen] = useState(false);
const [offboardingTarget, setOffboardingTarget] = useState<EmployeeSummary | null>(null);
const [targetDepartmentId, setTargetDepartmentId] = useState("");
const [targetManagerId, setTargetManagerId] = useState("");
@@ -155,6 +187,13 @@ export function PeoplePage() {
}),
});
const wecomHrMasterRunsQuery = useQuery({
queryKey: ["wecom-hr-master-sync", "runs"],
queryFn: () => fetchWecomHrMasterSyncRuns(5),
enabled: canManageUsers,
retry: false,
});
useEffect(() => {
setSelectedEmployeeIds([]);
}, [q, status, departmentId, title, managerId, hireDateFrom, hireDateTo, employmentStatus]);
@@ -221,6 +260,35 @@ export function PeoplePage() {
},
});
const wecomHrDryRunMutation = useMutation({
mutationFn: () => runWecomHrMasterSync({ mode: "dry_run", limit: 50 }),
onSuccess: async (run) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["wecom-hr-master-sync"] }),
invalidatePeopleQueries(),
]);
toast.success(`企微 HR 主数据预检完成:${formatSyncSummary(run)}`);
},
onError: (error) => {
toast.error(mapApiMessage(error, "企微 HR 主数据预检失败"));
},
});
const wecomHrApplyMutation = useMutation({
mutationFn: () => runWecomHrMasterSync({ mode: "apply", limit: 50 }),
onSuccess: async (run) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["wecom-hr-master-sync"] }),
invalidatePeopleQueries(),
]);
setWecomHrApplyConfirmOpen(false);
toast.success(`企微 HR 主数据写入完成:${formatSyncSummary(run)}`);
},
onError: (error) => {
toast.error(mapApiMessage(error, "企微 HR 主数据写入失败"));
},
});
const exportMutation = useMutation({
mutationFn: (format: "csv" | "xlsx") =>
exportEmployees({
@@ -320,8 +388,14 @@ export function PeoplePage() {
setManagerMutation.isPending ||
batchEnableMutation.isPending ||
batchDisableMutation.isPending ||
wecomDirectoryRetryMutation.isPending;
wecomDirectoryRetryMutation.isPending ||
wecomHrDryRunMutation.isPending ||
wecomHrApplyMutation.isPending;
const tableColumnCount = canManageUsers ? 8 : 7;
const latestWecomHrRun = wecomHrMasterRunsQuery.data?.items?.[0] ?? null;
const wecomHrMasterError = wecomHrMasterRunsQuery.isError
? mapApiMessage(wecomHrMasterRunsQuery.error, "企微 HR 主数据同步未就绪")
: "";
function toggleSelection(employeeId: string, checked: boolean) {
setSelectedEmployeeIds((current) => {
@@ -387,6 +461,54 @@ export function PeoplePage() {
}
/>
{canManageUsers ? (
<div className="card-std p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold text-zinc-900">企微 HR 主数据镜像</h2>
{latestWecomHrRun ? (
<StatusBadge
status={latestWecomHrRun.status}
label={`${wecomHrMasterModeLabel(latestWecomHrRun.mode)} · ${wecomHrMasterRunStatusLabel(latestWecomHrRun.status)}`}
/>
) : null}
</div>
<div className="text-sm text-zinc-600">
{latestWecomHrRun
? `${formatSyncSummary(latestWecomHrRun)} · ${latestWecomHrRun.finishedAt ?? latestWecomHrRun.createdAt ?? "-"}`
: "企微/人事助手作为人员主源;OA 保存镜像、账号、权限和审计。"}
</div>
{wecomHrMasterError ? (
<div className="text-xs text-amber-700">{wecomHrMasterError}</div>
) : null}
{latestWecomHrRun?.items?.length ? (
<div className="text-xs text-zinc-500">
最近失败项:{latestWecomHrRun.items.filter((item) => item.status === "failed" || item.status === "conflict").length}
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
disabled={batchPending}
onClick={() => wecomHrDryRunMutation.mutate()}
>
{wecomHrDryRunMutation.isPending ? "预检中..." : "同步预检"}
</Button>
<Button
type="button"
disabled={batchPending}
onClick={() => setWecomHrApplyConfirmOpen(true)}
>
{wecomHrApplyMutation.isPending ? "写入中..." : "确认写入"}
</Button>
</div>
</div>
</div>
) : null}
<div className="card-std space-y-4 p-4">
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
<Input
@@ -739,6 +861,17 @@ export function PeoplePage() {
}}
/>
<ConfirmDialog
open={wecomHrApplyConfirmOpen}
onOpenChange={setWecomHrApplyConfirmOpen}
title="写入企微 HR 主数据镜像"
description="将按当前企微/人事助手主数据创建或更新 OA 员工、部门、角色和企业微信绑定;不自动禁用不在职账号。"
confirmLabel={wecomHrApplyMutation.isPending ? "写入中..." : "确认写入"}
onConfirm={async () => {
await wecomHrApplyMutation.mutateAsync();
}}
/>
<ConfirmDialog
open={Boolean(offboardingTarget)}
onOpenChange={(open) => {