WHMS 实现企微HR主数据镜像同步
这个提交包含在:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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: "项目关联功能当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户