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

这个提交包含在:
2026-05-18 19:19:07 +08:00
父节点 3a6bfa0eaa
当前提交 27968fba49
共修改 24 个文件,包含 2123 行新增和 39 行删除
+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) => {