HRWD-0..9 实现人事生命周期企微通讯录同步
这个提交包含在:
@@ -7,6 +7,31 @@ export type EmployeeContractStatus = "active" | "expired" | "terminated";
|
|||||||
export type EmployeeTrainingType = "internal" | "external" | "online";
|
export type EmployeeTrainingType = "internal" | "external" | "online";
|
||||||
export type EmployeeTrainingStatus = "planned" | "in_progress" | "completed" | "cancelled";
|
export type EmployeeTrainingStatus = "planned" | "in_progress" | "completed" | "cancelled";
|
||||||
export type EmployeeCertificateStatus = "valid" | "expired";
|
export type EmployeeCertificateStatus = "valid" | "expired";
|
||||||
|
export type WecomDirectorySyncOperation = "create" | "update" | "disable";
|
||||||
|
export type WecomDirectorySyncStatus = "running" | "success" | "failed" | "skipped";
|
||||||
|
export type WecomDirectoryStateStatus = "unbound" | "bound" | "synced" | "sync_success" | "sync_skipped" | "sync_failed";
|
||||||
|
|
||||||
|
export type WecomDirectorySyncRun = {
|
||||||
|
id: string;
|
||||||
|
employeeId: string;
|
||||||
|
externalUserId: string | null;
|
||||||
|
operation: WecomDirectorySyncOperation;
|
||||||
|
status: WecomDirectorySyncStatus;
|
||||||
|
attempts: number;
|
||||||
|
errorCode: string | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
responseSummary: Record<string, unknown> | null;
|
||||||
|
triggeredBy: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WecomDirectoryState = {
|
||||||
|
status: WecomDirectoryStateStatus;
|
||||||
|
externalUserId: string | null;
|
||||||
|
bound: boolean;
|
||||||
|
latestSync: WecomDirectorySyncRun | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type EmployeeDepartmentSummary = {
|
export type EmployeeDepartmentSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,6 +56,7 @@ export type EmployeeSummary = {
|
|||||||
roleCodes: string[];
|
roleCodes: string[];
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
|
wecomDirectory?: WecomDirectoryState;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EmployeeListResponse = {
|
export type EmployeeListResponse = {
|
||||||
@@ -175,6 +201,7 @@ export type EmployeeProfile = {
|
|||||||
roleCodes: string[];
|
roleCodes: string[];
|
||||||
department: EmployeeDepartmentSummary | null;
|
department: EmployeeDepartmentSummary | null;
|
||||||
directManager: { id: string; employeeNo: string; name: string; avatarUrl: string | null } | null;
|
directManager: { id: string; employeeNo: string; name: string; avatarUrl: string | null } | null;
|
||||||
|
wecomDirectory?: WecomDirectoryState;
|
||||||
};
|
};
|
||||||
lifecycle: {
|
lifecycle: {
|
||||||
events: EmployeeLifecycleEvent[];
|
events: EmployeeLifecycleEvent[];
|
||||||
@@ -403,6 +430,33 @@ export async function fetchEmployeeLifecycle(employeeId: string) {
|
|||||||
return apiRequestData<EmployeeProfile["lifecycle"]>(`/api/employees/${employeeId}/lifecycle`);
|
return apiRequestData<EmployeeProfile["lifecycle"]>(`/api/employees/${employeeId}/lifecycle`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchEmployeeWecomDirectorySync(employeeId: string) {
|
||||||
|
return apiRequestData<{
|
||||||
|
employeeId: string;
|
||||||
|
wecomDirectory: WecomDirectoryState;
|
||||||
|
items: WecomDirectorySyncRun[];
|
||||||
|
}>(`/api/employees/${employeeId}/wecom-directory-sync`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryEmployeeWecomDirectorySync(employeeId: string, operation?: WecomDirectorySyncOperation) {
|
||||||
|
return apiRequestData<{
|
||||||
|
employeeId: string;
|
||||||
|
operation: WecomDirectorySyncOperation;
|
||||||
|
result: {
|
||||||
|
ok: boolean;
|
||||||
|
skipped: boolean;
|
||||||
|
status: WecomDirectorySyncStatus;
|
||||||
|
code: string;
|
||||||
|
message: string | null;
|
||||||
|
run: WecomDirectorySyncRun | null;
|
||||||
|
} | null;
|
||||||
|
wecomDirectory: WecomDirectoryState;
|
||||||
|
}>(`/api/employees/${employeeId}/wecom-directory-sync/retry`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(operation ? { operation } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchEmployeeOrganization() {
|
export async function fetchEmployeeOrganization() {
|
||||||
return apiRequestData<{ items: OrganizationDepartmentSection[] }>("/api/employees/organization");
|
return apiRequestData<{ items: OrganizationDepartmentSection[] }>("/api/employees/organization");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
patchEmployeeProfile,
|
patchEmployeeProfile,
|
||||||
patchEmployeeTraining,
|
patchEmployeeTraining,
|
||||||
regularizeEmployee,
|
regularizeEmployee,
|
||||||
|
retryEmployeeWecomDirectorySync,
|
||||||
skipEmployeeChecklistTask,
|
skipEmployeeChecklistTask,
|
||||||
startEmployeeOffboarding,
|
startEmployeeOffboarding,
|
||||||
transferEmployee,
|
transferEmployee,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
type EmployeeTraining,
|
type EmployeeTraining,
|
||||||
type EmployeeTrainingStatus,
|
type EmployeeTrainingStatus,
|
||||||
type EmployeeTrainingType,
|
type EmployeeTrainingType,
|
||||||
|
type WecomDirectoryState,
|
||||||
} from "../api/employees";
|
} from "../api/employees";
|
||||||
import { fetchEmployeePerformanceTimeline } from "../api/performance";
|
import { fetchEmployeePerformanceTimeline } from "../api/performance";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
@@ -151,6 +153,24 @@ function trainingTypeLabel(type: EmployeeTrainingType) {
|
|||||||
return TRAINING_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type;
|
return TRAINING_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wecomDirectoryStatusView(state?: WecomDirectoryState) {
|
||||||
|
const errorCode = state?.latestSync?.errorCode ?? "";
|
||||||
|
if (!state) return { label: "未同步", status: "not_triggered", hint: "无记录" };
|
||||||
|
if (state.status === "synced" || state.status === "sync_success") {
|
||||||
|
return { label: "同步成功", status: "succeeded", hint: state.externalUserId ?? "已绑定" };
|
||||||
|
}
|
||||||
|
if (state.status === "bound") return { label: "已绑定", status: "bound", hint: state.externalUserId ?? "已绑定" };
|
||||||
|
if (state.status === "sync_failed") return { label: "同步失败", status: "failed", hint: state.latestSync?.errorCode ?? "待重试" };
|
||||||
|
if (state.status === "sync_skipped") {
|
||||||
|
return {
|
||||||
|
label: errorCode === "wecom_contact_config_missing" || errorCode === "wecom_contact_sync_disabled" ? "配置缺失" : "已跳过",
|
||||||
|
status: "skipped",
|
||||||
|
hint: state.latestSync?.errorCode ?? "未执行",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { label: "未绑定", status: "not_triggered", hint: "待创建" };
|
||||||
|
}
|
||||||
|
|
||||||
export function EmployeeProfilePage() {
|
export function EmployeeProfilePage() {
|
||||||
const { employeeId = "" } = useParams();
|
const { employeeId = "" } = useParams();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -181,6 +201,7 @@ export function EmployeeProfilePage() {
|
|||||||
const [certificateForm, setCertificateForm] = useState<CertificateFormState>(buildEmptyCertificateForm());
|
const [certificateForm, setCertificateForm] = useState<CertificateFormState>(buildEmptyCertificateForm());
|
||||||
const [deleteAvatarConfirmOpen, setDeleteAvatarConfirmOpen] = useState(false);
|
const [deleteAvatarConfirmOpen, setDeleteAvatarConfirmOpen] = useState(false);
|
||||||
const [deleteContractTarget, setDeleteContractTarget] = useState<EmployeeContract | null>(null);
|
const [deleteContractTarget, setDeleteContractTarget] = useState<EmployeeContract | null>(null);
|
||||||
|
const [offboardingConfirmOpen, setOffboardingConfirmOpen] = useState(false);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: "",
|
title: "",
|
||||||
hireDate: "",
|
hireDate: "",
|
||||||
@@ -362,12 +383,34 @@ export function EmployeeProfilePage() {
|
|||||||
const offboardingMutation = useMutation({
|
const offboardingMutation = useMutation({
|
||||||
mutationFn: () => startEmployeeOffboarding(employeeId),
|
mutationFn: () => startEmployeeOffboarding(employeeId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
setOffboardingConfirmOpen(false);
|
||||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||||
toast.success("已发起离职");
|
toast.success("已发起离职");
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(errorMessage(error, "离职失败")),
|
onError: (error) => toast.error(errorMessage(error, "离职失败")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const wecomDirectoryRetryMutation = useMutation({
|
||||||
|
mutationFn: () => retryEmployeeWecomDirectorySync(employeeId),
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["employees"] }),
|
||||||
|
]);
|
||||||
|
const code = result.result?.code;
|
||||||
|
if (result.result?.skipped) {
|
||||||
|
toast.warning(code ? `企微同步已跳过:${code}` : "企微同步已跳过");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.result?.status === "failed") {
|
||||||
|
toast.error(code ? `企微同步失败:${code}` : "企微同步失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("企微同步已提交");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(errorMessage(error, "企微同步重试失败")),
|
||||||
|
});
|
||||||
|
|
||||||
const contractMutation = useMutation({
|
const contractMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -575,6 +618,7 @@ export function EmployeeProfilePage() {
|
|||||||
const contracts = contractsQuery.data?.items ?? [];
|
const contracts = contractsQuery.data?.items ?? [];
|
||||||
const trainings = trainingsQuery.data?.items ?? [];
|
const trainings = trainingsQuery.data?.items ?? [];
|
||||||
const certificates = certificatesQuery.data?.items ?? [];
|
const certificates = certificatesQuery.data?.items ?? [];
|
||||||
|
const wecomDirectoryView = wecomDirectoryStatusView(profile.employee.wecomDirectory);
|
||||||
|
|
||||||
function resetContractEditor() {
|
function resetContractEditor() {
|
||||||
setEditingContractId(null);
|
setEditingContractId(null);
|
||||||
@@ -673,7 +717,7 @@ export function EmployeeProfilePage() {
|
|||||||
<Button type="button" variant="secondary" onClick={() => transferMutation.mutate()} disabled={transferMutation.isPending}>
|
<Button type="button" variant="secondary" onClick={() => transferMutation.mutate()} disabled={transferMutation.isPending}>
|
||||||
记录调岗
|
记录调岗
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={() => offboardingMutation.mutate()} disabled={offboardingMutation.isPending}>
|
<Button type="button" onClick={() => setOffboardingConfirmOpen(true)} disabled={offboardingMutation.isPending}>
|
||||||
发起离职
|
发起离职
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -760,6 +804,28 @@ export function EmployeeProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-zinc-200 p-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-zinc-500">企业微信通讯录</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<StatusBadge status={wecomDirectoryView.status} label={wecomDirectoryView.label} />
|
||||||
|
<span className="text-sm text-zinc-500">{wecomDirectoryView.hint}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManageEmployeeProfile ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => wecomDirectoryRetryMutation.mutate()}
|
||||||
|
disabled={wecomDirectoryRetryMutation.isPending}
|
||||||
|
>
|
||||||
|
{wecomDirectoryRetryMutation.isPending ? "同步中..." : "重试同步"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{canEditExtendedFields ? (
|
{canEditExtendedFields ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -1459,6 +1525,16 @@ export function EmployeeProfilePage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ActionConfirmDialog
|
||||||
|
open={offboardingConfirmOpen}
|
||||||
|
onOpenChange={setOffboardingConfirmOpen}
|
||||||
|
kind="delete"
|
||||||
|
targetName={profile.employee.name}
|
||||||
|
description="将发起离职清单;清单完成后会禁用 OA 账号并尝试禁用企业微信成员。"
|
||||||
|
onConfirm={async () => {
|
||||||
|
offboardingMutation.mutate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-5
@@ -9,9 +9,12 @@ import {
|
|||||||
fetchEmployees,
|
fetchEmployees,
|
||||||
fetchEmployeeOrganization,
|
fetchEmployeeOrganization,
|
||||||
regularizeEmployee,
|
regularizeEmployee,
|
||||||
|
retryEmployeeWecomDirectorySync,
|
||||||
startEmployeeOffboarding,
|
startEmployeeOffboarding,
|
||||||
startEmployeeOnboarding,
|
startEmployeeOnboarding,
|
||||||
type EmployeeEmploymentStatus,
|
type EmployeeEmploymentStatus,
|
||||||
|
type EmployeeSummary,
|
||||||
|
type WecomDirectoryState,
|
||||||
type OrganizationTreeNode,
|
type OrganizationTreeNode,
|
||||||
} from "../api/employees";
|
} from "../api/employees";
|
||||||
import { exportEmployees } from "../api/exports";
|
import { exportEmployees } from "../api/exports";
|
||||||
@@ -59,6 +62,34 @@ function employmentStatusLabel(status: EmployeeEmploymentStatus | null) {
|
|||||||
return EMPLOYMENT_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? "-";
|
return EMPLOYMENT_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wecomDirectoryStatusView(state?: WecomDirectoryState) {
|
||||||
|
const errorCode = state?.latestSync?.errorCode ?? "";
|
||||||
|
if (!state) {
|
||||||
|
return { label: "未同步", status: "not_triggered", hint: "无记录" };
|
||||||
|
}
|
||||||
|
if (state.status === "synced" || state.status === "sync_success") {
|
||||||
|
return { label: "同步成功", status: "succeeded", hint: state.externalUserId ?? "已绑定" };
|
||||||
|
}
|
||||||
|
if (state.status === "bound") {
|
||||||
|
return { label: "已绑定", status: "bound", hint: state.externalUserId ?? "已绑定" };
|
||||||
|
}
|
||||||
|
if (state.status === "sync_failed") {
|
||||||
|
return { label: "同步失败", status: "failed", hint: state.latestSync?.errorCode ?? "待重试" };
|
||||||
|
}
|
||||||
|
if (state.status === "sync_skipped") {
|
||||||
|
return {
|
||||||
|
label: errorCode === "wecom_contact_config_missing" || errorCode === "wecom_contact_sync_disabled" ? "配置缺失" : "已跳过",
|
||||||
|
status: "skipped",
|
||||||
|
hint: state.latestSync?.errorCode ?? "未执行",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { label: "未绑定", status: "not_triggered", hint: "待创建" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowWecomRetry(state?: WecomDirectoryState) {
|
||||||
|
return !state || ["unbound", "sync_failed", "sync_skipped"].includes(state.status);
|
||||||
|
}
|
||||||
|
|
||||||
function collectOrganizationEmployees(nodes: OrganizationTreeNode[], target: Map<string, { id: string; name: string; employeeNo: string; departmentName: string }>) {
|
function collectOrganizationEmployees(nodes: OrganizationTreeNode[], target: Map<string, { id: string; name: string; employeeNo: string; departmentName: string }>) {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (!target.has(node.id)) {
|
if (!target.has(node.id)) {
|
||||||
@@ -93,6 +124,7 @@ export function PeoplePage() {
|
|||||||
const [managerDialogOpen, setManagerDialogOpen] = useState(false);
|
const [managerDialogOpen, setManagerDialogOpen] = useState(false);
|
||||||
const [enableConfirmOpen, setEnableConfirmOpen] = useState(false);
|
const [enableConfirmOpen, setEnableConfirmOpen] = useState(false);
|
||||||
const [disableConfirmOpen, setDisableConfirmOpen] = useState(false);
|
const [disableConfirmOpen, setDisableConfirmOpen] = useState(false);
|
||||||
|
const [offboardingTarget, setOffboardingTarget] = useState<EmployeeSummary | null>(null);
|
||||||
const [targetDepartmentId, setTargetDepartmentId] = useState("");
|
const [targetDepartmentId, setTargetDepartmentId] = useState("");
|
||||||
const [targetManagerId, setTargetManagerId] = useState("");
|
const [targetManagerId, setTargetManagerId] = useState("");
|
||||||
|
|
||||||
@@ -161,6 +193,7 @@ export function PeoplePage() {
|
|||||||
mutationFn: (employeeId: string) => startEmployeeOffboarding(employeeId),
|
mutationFn: (employeeId: string) => startEmployeeOffboarding(employeeId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await invalidatePeopleQueries();
|
await invalidatePeopleQueries();
|
||||||
|
setOffboardingTarget(null);
|
||||||
toast.success("已发起离职流程");
|
toast.success("已发起离职流程");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -168,6 +201,26 @@ export function PeoplePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const wecomDirectoryRetryMutation = useMutation({
|
||||||
|
mutationFn: (employeeId: string) => retryEmployeeWecomDirectorySync(employeeId),
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
await invalidatePeopleQueries();
|
||||||
|
const code = result.result?.code;
|
||||||
|
if (result.result?.skipped) {
|
||||||
|
toast.warning(code ? `企微同步已跳过:${code}` : "企微同步已跳过");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.result?.status === "failed") {
|
||||||
|
toast.error(code ? `企微同步失败:${code}` : "企微同步失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("企微同步已提交");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(mapApiMessage(error, "企微同步重试失败"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const exportMutation = useMutation({
|
const exportMutation = useMutation({
|
||||||
mutationFn: (format: "csv" | "xlsx") =>
|
mutationFn: (format: "csv" | "xlsx") =>
|
||||||
exportEmployees({
|
exportEmployees({
|
||||||
@@ -266,8 +319,9 @@ export function PeoplePage() {
|
|||||||
transferDepartmentMutation.isPending ||
|
transferDepartmentMutation.isPending ||
|
||||||
setManagerMutation.isPending ||
|
setManagerMutation.isPending ||
|
||||||
batchEnableMutation.isPending ||
|
batchEnableMutation.isPending ||
|
||||||
batchDisableMutation.isPending;
|
batchDisableMutation.isPending ||
|
||||||
const tableColumnCount = canManageUsers ? 7 : 6;
|
wecomDirectoryRetryMutation.isPending;
|
||||||
|
const tableColumnCount = canManageUsers ? 8 : 7;
|
||||||
|
|
||||||
function toggleSelection(employeeId: string, checked: boolean) {
|
function toggleSelection(employeeId: string, checked: boolean) {
|
||||||
setSelectedEmployeeIds((current) => {
|
setSelectedEmployeeIds((current) => {
|
||||||
@@ -470,12 +524,15 @@ export function PeoplePage() {
|
|||||||
<TableHead>岗位 / 雇用状态</TableHead>
|
<TableHead>岗位 / 雇用状态</TableHead>
|
||||||
<TableHead>角色</TableHead>
|
<TableHead>角色</TableHead>
|
||||||
<TableHead>账号状态</TableHead>
|
<TableHead>账号状态</TableHead>
|
||||||
|
<TableHead>企业微信</TableHead>
|
||||||
<TableHead>操作</TableHead>
|
<TableHead>操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length ? (
|
{items.length ? (
|
||||||
items.map((item) => (
|
items.map((item) => {
|
||||||
|
const wecomView = wecomDirectoryStatusView(item.wecomDirectory);
|
||||||
|
return (
|
||||||
<TableRow key={item.id}>
|
<TableRow key={item.id}>
|
||||||
{canManageUsers ? (
|
{canManageUsers ? (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -520,6 +577,12 @@ export function PeoplePage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusBadge status={item.status} />
|
<StatusBadge status={item.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<StatusBadge status={wecomView.status} label={wecomView.label} />
|
||||||
|
<div className="max-w-40 truncate text-xs text-zinc-500">{wecomView.hint}</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button asChild size="sm" variant="secondary" type="button">
|
<Button asChild size="sm" variant="secondary" type="button">
|
||||||
@@ -533,7 +596,24 @@ export function PeoplePage() {
|
|||||||
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
|
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
|
||||||
转正
|
转正
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="secondary" type="button" disabled={offboardingMutation.isPending} onClick={() => offboardingMutation.mutate(item.id)}>
|
{shouldShowWecomRetry(item.wecomDirectory) ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={wecomDirectoryRetryMutation.isPending}
|
||||||
|
onClick={() => wecomDirectoryRetryMutation.mutate(item.id)}
|
||||||
|
>
|
||||||
|
企微重试
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={offboardingMutation.isPending}
|
||||||
|
onClick={() => setOffboardingTarget(item)}
|
||||||
|
>
|
||||||
发起离职
|
发起离职
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -541,7 +621,8 @@ export function PeoplePage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="py-3 text-zinc-500" colSpan={tableColumnCount}>
|
<TableCell className="py-3 text-zinc-500" colSpan={tableColumnCount}>
|
||||||
@@ -657,6 +738,26 @@ export function PeoplePage() {
|
|||||||
await batchDisableMutation.mutateAsync();
|
await batchDisableMutation.mutateAsync();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(offboardingTarget)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setOffboardingTarget(null);
|
||||||
|
}}
|
||||||
|
title="发起离职流程"
|
||||||
|
description={
|
||||||
|
offboardingTarget
|
||||||
|
? `将为 ${offboardingTarget.name} 发起离职清单;清单完成后会禁用 OA 账号并尝试禁用企业微信成员。`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
confirmLabel={offboardingMutation.isPending ? "处理中..." : "确认发起"}
|
||||||
|
variant="destructive"
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (offboardingTarget) {
|
||||||
|
await offboardingMutation.mutateAsync(offboardingTarget.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ WECOM_REDIRECT_URI=https://oa.example.com/api/auth/wecom/callback
|
|||||||
WECOM_ALLOWED_RETURN_HOSTS=oa.example.com
|
WECOM_ALLOWED_RETURN_HOSTS=oa.example.com
|
||||||
WECOM_CHECKIN_SYNC_ENABLED=false
|
WECOM_CHECKIN_SYNC_ENABLED=false
|
||||||
WECOM_CHECKIN_SECRET=
|
WECOM_CHECKIN_SECRET=
|
||||||
|
WECOM_CONTACT_SYNC_ENABLED=false
|
||||||
|
WECOM_CONTACT_SECRET=
|
||||||
|
WECOM_CONTACT_DEFAULT_DEPARTMENT_ID=
|
||||||
IM_NOTIFIER_ENABLED=true
|
IM_NOTIFIER_ENABLED=true
|
||||||
|
|
||||||
# ===== 数据库(Compose 内部连接) =====
|
# ===== 数据库(Compose 内部连接) =====
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
|||||||
- 2026-05-17 ATTV1 已完成考勤统一入口实现并部署到公网:历史 OA 自建打卡 `attendance_v1` 关闭;OA 新增企微打卡摘要同步、补卡/外出/加班修正审批和月度考勤解释;所有审批继续统一在 OA,企业微信只负责打卡原始采集和风控。公网复检显示旧打卡 API 返回 409、统一考勤月度汇总和修正列表可读、移动/企业微信矩阵 14 passed;企微 live 同步依赖 `WECOM_CHECKIN_SECRET` 和企微打卡 API 权限,缺配置时记 `SKIP`。
|
- 2026-05-17 ATTV1 已完成考勤统一入口实现并部署到公网:历史 OA 自建打卡 `attendance_v1` 关闭;OA 新增企微打卡摘要同步、补卡/外出/加班修正审批和月度考勤解释;所有审批继续统一在 OA,企业微信只负责打卡原始采集和风控。公网复检显示旧打卡 API 返回 409、统一考勤月度汇总和修正列表可读、移动/企业微信矩阵 14 passed;企微 live 同步依赖 `WECOM_CHECKIN_SECRET` 和企微打卡 API 权限,缺配置时记 `SKIP`。
|
||||||
- 2026-05-17 LAUNCH-AUTO-OPS-V1 已完成正式启用后自动化收口脚本和证据刷新:本地 Prisma/node check/focused 单测、前端 typecheck/build、公网 readiness、PDF live OCR、统一考勤 live 检查、公网移动矩阵和企业微信 UA/渲染矩阵均通过;显式企微打卡同步检查返回 `SKIP wecom_checkin_sync_disabled`;但只读 flag 检查发现当前公网 `payment_batch_v1=force_off`,与 FIN-PROD-ENABLE 记录的正式常开口径不一致,结论为 `PAUSE_FOR_FIX`。runner 已跳过付款批次写入,未切换 flag、未部署、不接真实支付、不接 ERP。
|
- 2026-05-17 LAUNCH-AUTO-OPS-V1 已完成正式启用后自动化收口脚本和证据刷新:本地 Prisma/node check/focused 单测、前端 typecheck/build、公网 readiness、PDF live OCR、统一考勤 live 检查、公网移动矩阵和企业微信 UA/渲染矩阵均通过;显式企微打卡同步检查返回 `SKIP wecom_checkin_sync_disabled`;但只读 flag 检查发现当前公网 `payment_batch_v1=force_off`,与 FIN-PROD-ENABLE 记录的正式常开口径不一致,结论为 `PAUSE_FOR_FIX`。runner 已跳过付款批次写入,未切换 flag、未部署、不接真实支付、不接 ERP。
|
||||||
- 2026-05-17 AUTO-REMAINING-CLOSEOUT 已完成剩余无需人工操作收口:受控恢复公网 `payment_batch_v1=force_on`,保持 `erp_sync=force_off`;复跑 `run-launch-auto-ops.sh` 通过,公网测试报销 `BX-20260517-0027` 完成审批、付款批次创建/复核、回单上传/下载、付款执行、AP 导出和审计核验;公网移动矩阵 6 passed、企业微信渲染矩阵 8 passed。显式企微打卡同步仍为 `SKIP wecom_checkin_sync_disabled`,属于外部后台配置缺口,不阻断 OA 统一考勤入口和正式运营门禁;结论 `GO_TO_OPERATE`。
|
- 2026-05-17 AUTO-REMAINING-CLOSEOUT 已完成剩余无需人工操作收口:受控恢复公网 `payment_batch_v1=force_on`,保持 `erp_sync=force_off`;复跑 `run-launch-auto-ops.sh` 通过,公网测试报销 `BX-20260517-0027` 完成审批、付款批次创建/复核、回单上传/下载、付款执行、AP 导出和审计核验;公网移动矩阵 6 passed、企业微信渲染矩阵 8 passed。显式企微打卡同步仍为 `SKIP wecom_checkin_sync_disabled`,属于外部后台配置缺口,不阻断 OA 统一考勤入口和正式运营门禁;结论 `GO_TO_OPERATE`。
|
||||||
|
- 2026-05-17 HR-WECOM-DIRECTORY-V1 已完成并部署公网:产品口径已调整为 ERP 未来不接入 OA;人事生命周期和考勤同属 HR 大域但分模块实现。OA 继续作为员工、权限、审批和审计主事实源;企业微信作为通讯录、入口、通知和打卡采集基础设施。HRWD 新增企业微信通讯录同步 flag、配置、同步记录、入职创建/资料更新/离职禁用钩子和员工管理重试入口。本地后端/前端/Playwright 通过,部署后公网 readiness、PDF live OCR、HRWD 端点 SKIP 检查和公网移动/企微矩阵通过;部署过程中发现 `seed-config` 会覆盖正式 feature flag,已修正为只创建缺失开关并复部署验证,最终 `payment_batch_v1=force_on`、`erp_sync=force_off`。
|
||||||
|
|
||||||
## 关键证据索引
|
## 关键证据索引
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
|||||||
| ATTV1 | 已收口:考勤统一入口与企微打卡同步 V1 已部署公网;关闭 OA 自建打卡,新增企微打卡摘要同步、OA 补卡/外出/加班审批和月度解释;公网 readiness、移动/企微矩阵和考勤关键接口复检通过 | `issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv`、`artifacts/qa-flow/live-smoke-readiness-20260517-171535/summary.json`、`docs/需求-请假考勤修正规约.md`、`docs/需求-企业微信基础设施规约.md` |
|
| ATTV1 | 已收口:考勤统一入口与企微打卡同步 V1 已部署公网;关闭 OA 自建打卡,新增企微打卡摘要同步、OA 补卡/外出/加班审批和月度解释;公网 readiness、移动/企微矩阵和考勤关键接口复检通过 | `issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv`、`artifacts/qa-flow/live-smoke-readiness-20260517-171535/summary.json`、`docs/需求-请假考勤修正规约.md`、`docs/需求-企业微信基础设施规约.md` |
|
||||||
| LAUNCH-AUTO-OPS-V1 | 阶段收口:自动化证据完成,结论 `PAUSE_FOR_FIX`;除付款批次正式 flag 漂移外,本地门禁、公网 readiness/PDF live OCR、统一考勤、公网移动和企业微信 UA/渲染矩阵均通过;付款批次写入因前置失败被跳过 | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv`、`artifacts/qa-flow/launch-auto-ops-20260517-175548/summary.json`、`artifacts/qa-flow/live-smoke-readiness-20260517-175755/summary.json` |
|
| LAUNCH-AUTO-OPS-V1 | 阶段收口:自动化证据完成,结论 `PAUSE_FOR_FIX`;除付款批次正式 flag 漂移外,本地门禁、公网 readiness/PDF live OCR、统一考勤、公网移动和企业微信 UA/渲染矩阵均通过;付款批次写入因前置失败被跳过 | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv`、`artifacts/qa-flow/launch-auto-ops-20260517-175548/summary.json`、`artifacts/qa-flow/live-smoke-readiness-20260517-175755/summary.json` |
|
||||||
| AUTO-REMAINING-CLOSEOUT | 已收口:剩余自动化运营门禁通过,结论 `GO_TO_OPERATE`;公网 `payment_batch_v1=force_on`、`erp_sync=force_off`,付款批次常开闭环、AP 导出、审计、公网移动和企业微信渲染矩阵均通过;企微打卡同步外部配置仍记 `SKIP` | `issues/2026-05-17_19-30-44-auto-remaining-closeout.csv`、`artifacts/qa-flow/launch-auto-ops-20260517-193317/summary.json`、`artifacts/qa-flow/launch-auto-ops-20260517-193317/live-result.json` |
|
| AUTO-REMAINING-CLOSEOUT | 已收口:剩余自动化运营门禁通过,结论 `GO_TO_OPERATE`;公网 `payment_batch_v1=force_on`、`erp_sync=force_off`,付款批次常开闭环、AP 导出、审计、公网移动和企业微信渲染矩阵均通过;企微打卡同步外部配置仍记 `SKIP` | `issues/2026-05-17_19-30-44-auto-remaining-closeout.csv`、`artifacts/qa-flow/launch-auto-ops-20260517-193317/summary.json`、`artifacts/qa-flow/launch-auto-ops-20260517-193317/live-result.json` |
|
||||||
|
| HR-WECOM-DIRECTORY-V1 | 已收口:人事生命周期与企业微信通讯录同步闭环已部署公网;ERP 改为未来不接入 OA;员工创建/入职/调岗/资料变更/离职可触发企微通讯录创建、更新、禁用并记录同步状态;缺外部配置时公网重试记录 `SKIP wecom_contact_sync_disabled`,不阻断 OA 人事流程;部署 seed 已修正为不覆盖正式 flag | `issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv`、`server/tests/unit/routes/employees.wecomDirectory.test.js`、`artifacts/qa-flow/live-smoke-readiness-20260517-205813/summary.json`、`docs/需求-企业微信基础设施规约.md` |
|
||||||
|
|
||||||
## 当前门禁
|
## 当前门禁
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -58,6 +58,7 @@
|
|||||||
| `ATTV1` | `issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv` | `EVIDENCE` | 考勤统一入口与企微打卡同步 V1 已收口:提交 `916cc3b` 和 `b2b4447` 已部署到公网;历史 OA 自建打卡关闭,补卡/外出/加班 OA 审批和月度解释可用;公网 readiness、PDF live OCR、移动/企业微信矩阵 14 passed,旧打卡 API 返回 409,企微打卡同步缺配置时记录 `SKIP` |
|
| `ATTV1` | `issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv` | `EVIDENCE` | 考勤统一入口与企微打卡同步 V1 已收口:提交 `916cc3b` 和 `b2b4447` 已部署到公网;历史 OA 自建打卡关闭,补卡/外出/加班 OA 审批和月度解释可用;公网 readiness、PDF live OCR、移动/企业微信矩阵 14 passed,旧打卡 API 返回 409,企微打卡同步缺配置时记录 `SKIP` |
|
||||||
| `LAUNCH-AUTO-OPS-V1` | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv` | `EVIDENCE` | 正式启用后自动化收口已阶段完成,结论 `PAUSE_FOR_FIX`:本地门禁、公网 readiness/PDF live OCR、考勤统一入口、移动矩阵和企业微信 UA/渲染矩阵通过;企微打卡同步显式检查为 `SKIP wecom_checkin_sync_disabled`;公网 `payment_batch_v1=force_off` 与正式常开口径不一致,付款批次写入已安全跳过 |
|
| `LAUNCH-AUTO-OPS-V1` | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv` | `EVIDENCE` | 正式启用后自动化收口已阶段完成,结论 `PAUSE_FOR_FIX`:本地门禁、公网 readiness/PDF live OCR、考勤统一入口、移动矩阵和企业微信 UA/渲染矩阵通过;企微打卡同步显式检查为 `SKIP wecom_checkin_sync_disabled`;公网 `payment_batch_v1=force_off` 与正式常开口径不一致,付款批次写入已安全跳过 |
|
||||||
| `AUTO-REMAINING-CLOSEOUT` | `issues/2026-05-17_19-30-44-auto-remaining-closeout.csv` | `EVIDENCE` | 剩余无需人工操作事项已收口,结论 `GO_TO_OPERATE`:公网 `payment_batch_v1` 已恢复 `force_on`,`erp_sync=force_off`;正式自动巡检报告 `artifacts/qa-flow/launch-auto-ops-20260517-193317/` 为 PASS,付款批次常开闭环、移动矩阵和企业微信渲染矩阵通过;企微打卡同步仍因外部配置 `SKIP wecom_checkin_sync_disabled` |
|
| `AUTO-REMAINING-CLOSEOUT` | `issues/2026-05-17_19-30-44-auto-remaining-closeout.csv` | `EVIDENCE` | 剩余无需人工操作事项已收口,结论 `GO_TO_OPERATE`:公网 `payment_batch_v1` 已恢复 `force_on`,`erp_sync=force_off`;正式自动巡检报告 `artifacts/qa-flow/launch-auto-ops-20260517-193317/` 为 PASS,付款批次常开闭环、移动矩阵和企业微信渲染矩阵通过;企微打卡同步仍因外部配置 `SKIP wecom_checkin_sync_disabled` |
|
||||||
|
| `HR-WECOM-DIRECTORY-V1` | `issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv` | `EVIDENCE` | 人事生命周期与企业微信通讯录同步已部署并收口:ERP 未来不接入 OA;HR 大域拆分为人事生命周期和考勤两个模块;OA 驱动企微通讯录创建/更新/禁用,失败不回滚 OA 人事动作;公网端点检查显示缺通讯录配置时重试记录 `SKIP wecom_contact_sync_disabled`,不阻断 OA 人事闭环;seed-config 已改为不覆盖已有正式 feature flag,复部署后 `payment_batch_v1=force_on`、`erp_sync=force_off` |
|
||||||
|
|
||||||
旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。
|
旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。
|
||||||
|
|
||||||
@@ -65,9 +66,9 @@
|
|||||||
|
|
||||||
| Doc_ID | Path | Category | Authority | Status | Owner | Related_Issues | Evidence | Supersedes | Last_Audited | Audit_Method | Risk | Notes |
|
| Doc_ID | Path | Category | Authority | Status | Owner | Related_Issues | Evidence | Supersedes | Last_Audited | Audit_Method | Risk | Notes |
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT` | `rg` | none | 2026-05-17 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 |
|
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1` | `rg` | none | 2026-05-17 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 |
|
||||||
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT` | `audit-docs` | none | 2026-05-17 | `audit-docs --check-registry` | medium | 判断文档权威性 |
|
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1` | `audit-docs` | none | 2026-05-17 | `audit-docs --check-registry` | medium | 判断文档权威性 |
|
||||||
| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8` | Issue CSV | `docs/requirements-specification.md` | 2026-05-13 | `rg` | high | 定义做什么、不做什么和验收口径 |
|
| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8,HR-WECOM-DIRECTORY-V1` | Issue CSV | `docs/requirements-specification.md` | 2026-05-17 | `rg` | high | 定义做什么、不做什么和验收口径;ERP 未来不接入 OA |
|
||||||
| `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T` | `rg` | none | 2026-05-13 | `rg` | high | 记录已拍板产品边界 |
|
| `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T` | `rg` | none | 2026-05-13 | `rg` | high | 记录已拍板产品边界 |
|
||||||
| `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9` | Playwright/视觉审查/DG8/DG9 | none | 2026-05-13 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型 |
|
| `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9` | Playwright/视觉审查/DG8/DG9 | none | 2026-05-13 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型 |
|
||||||
| `DELIVERY` | `docs/开发交付与变更管理规约.md` | 开发治理 | 开发交付事实源 | `CURRENT` | dev/qa | `GOV8` | `rg` | none | 2026-05-13 | `rg` | high | Issue、review、commit 和交付规则 |
|
| `DELIVERY` | `docs/开发交付与变更管理规约.md` | 开发治理 | 开发交付事实源 | `CURRENT` | dev/qa | `GOV8` | `rg` | none | 2026-05-13 | `rg` | high | Issue、review、commit 和交付规则 |
|
||||||
@@ -78,7 +79,7 @@
|
|||||||
| `REQ-WF` | `docs/需求-统一审批中心规约.md` | 需求事实源 | 审批模块规约 | `CURRENT` | product/backend | `AP6,WF8` | workflow 回归 | none | 2026-05-13 | `rg` | high | 审批、催办、通知和状态流 |
|
| `REQ-WF` | `docs/需求-统一审批中心规约.md` | 需求事实源 | 审批模块规约 | `CURRENT` | product/backend | `AP6,WF8` | workflow 回归 | none | 2026-05-13 | `rg` | high | 审批、催办、通知和状态流 |
|
||||||
| `REQ-OCR` | `docs/需求-报销发票OCR规约.md` | 需求事实源 | OCR 模块规约 | `CURRENT` | product/backend | `RI6,OCR8,BIZ8` | OCR 回归 | none | 2026-05-13 | `rg` | high | OCR 和发票边界 |
|
| `REQ-OCR` | `docs/需求-报销发票OCR规约.md` | 需求事实源 | OCR 模块规约 | `CURRENT` | product/backend | `RI6,OCR8,BIZ8` | OCR 回归 | none | 2026-05-13 | `rg` | high | OCR 和发票边界 |
|
||||||
| `RULE-REIMB` | `docs/报销发票附件规则.md` | 需求事实源 | 报销发票附件规则 | `CURRENT` | product/finance | `RI6,BIZ8` | 报销回归 | none | 2026-05-13 | `rg` | high | 报销、发票、附件细则 |
|
| `RULE-REIMB` | `docs/报销发票附件规则.md` | 需求事实源 | 报销发票附件规则 | `CURRENT` | product/finance | `RI6,BIZ8` | 报销回归 | none | 2026-05-13 | `rg` | high | 报销、发票、附件细则 |
|
||||||
| `REQ-WECOM` | `docs/需求-企业微信基础设施规约.md` | 需求事实源 | 企业微信边界规约 | `CURRENT` | product/backend | `R7E,ORG8,WF8` | live smoke/真机反馈 | none | 2026-05-13 | `rg` | high | 企业微信只做入口、身份和通知 |
|
| `REQ-WECOM` | `docs/需求-企业微信基础设施规约.md` | 需求事实源 | 企业微信边界规约 | `CURRENT` | product/backend | `R7E,ORG8,WF8,ATTV1,HR-WECOM-DIRECTORY-V1` | live smoke/真机反馈/Issue CSV | none | 2026-05-17 | `rg` | high | 企业微信只做通讯录、入口、身份、通知和打卡采集基础设施,不做原生审批 |
|
||||||
| `RUNBOOK-TRIAL` | `docs/试运行与自动化验证手册.md` | 操作手册 | 试运行验证手册 | `CURRENT` | qa/ops | `R7E,QAOPS8` | live readiness | none | 2026-05-13 | `rg` | medium | 本地、公网、企业微信和真实 OCR 验证 |
|
| `RUNBOOK-TRIAL` | `docs/试运行与自动化验证手册.md` | 操作手册 | 试运行验证手册 | `CURRENT` | qa/ops | `R7E,QAOPS8` | live readiness | none | 2026-05-13 | `rg` | medium | 本地、公网、企业微信和真实 OCR 验证 |
|
||||||
| `MANUAL-ADMIN` | `docs/管理员手册.md` | 操作手册 | 管理员手册 | `CURRENT` | ops/admin | `ORG8,R7E` | 人工验收 | none | 2026-05-13 | `rg` | medium | 管理员上线和配置 |
|
| `MANUAL-ADMIN` | `docs/管理员手册.md` | 操作手册 | 管理员手册 | `CURRENT` | ops/admin | `ORG8,R7E` | 人工验收 | none | 2026-05-13 | `rg` | medium | 管理员上线和配置 |
|
||||||
| `MANUAL-EMPLOYEE` | `docs/员工快速上手.md` | 操作手册 | 员工手册 | `CURRENT` | product | `R7E` | 人工验收 | none | 2026-05-13 | `rg` | low | 员工侧使用说明 |
|
| `MANUAL-EMPLOYEE` | `docs/员工快速上手.md` | 操作手册 | 员工手册 | `CURRENT` | product | `R7E` | 人工验收 | none | 2026-05-13 | `rg` | low | 员工侧使用说明 |
|
||||||
@@ -171,4 +172,4 @@
|
|||||||
|
|
||||||
- `ME6-3` 代表性企业微信真机门禁已关闭。
|
- `ME6-3` 代表性企业微信真机门禁已关闭。
|
||||||
- MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检。
|
- MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检。
|
||||||
- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同、PRELAUNCH 正式启用前公网预回归、CASHIER-VOUCHER-V1 财务闭环与银行回单附件、LAUNCH-DEPLOY-FINANCE 部署复检、FIN-PROD-ENABLE 正式常开、ACCOUNTING-HANDOFF-V1 代账交接 SOP、SYSTEM-COMPLETE-GATE 系统完成门禁、LAUNCH-OPS-V1 首周运营保障、REQ-COMPLETE-AUDIT 需求完成度复核、ATTV1 考勤统一入口和 AUTO-REMAINING-CLOSEOUT 剩余自动化收口均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同。LAUNCH-AUTO-OPS-V1 发现的公网 `payment_batch_v1=force_off` 漂移已由 AUTOREM 恢复为 `force_on`,正式自动巡检结论为 `GO_TO_OPERATE`;`erp_sync=force_off`,正式支付接口、ERP、图片 OCR 和银行流水导入继续冻结。ATTV1 已部署:历史 OA 自建打卡 `attendance_v1` 关闭,OA 补卡/外出/加班审批和月度考勤解释已纳入统一考勤主线;企微打卡 live 同步能力依赖 `WECOM_CHECKIN_SECRET` 和企微 API 权限,缺配置时记录 `SKIP`。
|
- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同、PRELAUNCH 正式启用前公网预回归、CASHIER-VOUCHER-V1 财务闭环与银行回单附件、LAUNCH-DEPLOY-FINANCE 部署复检、FIN-PROD-ENABLE 正式常开、ACCOUNTING-HANDOFF-V1 代账交接 SOP、SYSTEM-COMPLETE-GATE 系统完成门禁、LAUNCH-OPS-V1 首周运营保障、REQ-COMPLETE-AUDIT 需求完成度复核、ATTV1 考勤统一入口和 AUTO-REMAINING-CLOSEOUT 剩余自动化收口均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同。LAUNCH-AUTO-OPS-V1 发现的公网 `payment_batch_v1=force_off` 漂移已由 AUTOREM 恢复为 `force_on`,正式自动巡检结论为 `GO_TO_OPERATE`;`erp_sync=force_off`,ERP 未来不接入 OA,正式支付接口、图片 OCR 和银行流水导入继续不进入当前路线。ATTV1 已部署:历史 OA 自建打卡 `attendance_v1` 关闭,OA 补卡/外出/加班审批和月度考勤解释已纳入统一考勤主线;企微打卡 live 同步能力依赖 `WECOM_CHECKIN_SECRET` 和企微 API 权限,缺配置时记录 `SKIP`。HRWD 当前补齐 OA 驱动企业微信通讯录同步,live 写入同样依赖通讯录 Secret、默认部门和 API 权限。
|
||||||
|
|||||||
+4
-2
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
## 最近事实源链
|
## 最近事实源链
|
||||||
|
|
||||||
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT。
|
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1。
|
||||||
|
|
||||||
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
|
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
|
||||||
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
|
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
|
||||||
@@ -52,6 +52,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
|||||||
- ATTV1 是 REQ-COMPLETE-AUDIT 后的考勤统一入口实现合同:已部署到公网,关闭历史 OA 自建打卡 `attendance_v1`;新增 `attendance_unified_v1`、企微打卡摘要同步批次、补卡/外出/加班 OA 审批和月度考勤解释;公网复检 `/ready`、live smoke、PDF live OCR、移动/企业微信矩阵和考勤关键接口均通过,企微 live 同步缺 Secret 或 API 权限时记 `SKIP`,不使用企微原生审批,不保存完整位置、人脸、设备明文或原始 payload。
|
- ATTV1 是 REQ-COMPLETE-AUDIT 后的考勤统一入口实现合同:已部署到公网,关闭历史 OA 自建打卡 `attendance_v1`;新增 `attendance_unified_v1`、企微打卡摘要同步批次、补卡/外出/加班 OA 审批和月度考勤解释;公网复检 `/ready`、live smoke、PDF live OCR、移动/企业微信矩阵和考勤关键接口均通过,企微 live 同步缺 Secret 或 API 权限时记 `SKIP`,不使用企微原生审批,不保存完整位置、人脸、设备明文或原始 payload。
|
||||||
- LAUNCH-AUTO-OPS-V1 是 ATTV1 后的正式启用自动化收口合同:新增当前正式状态 QA-only runner、live feature flag 只读检查和统一考勤 live 检查;本地门禁、公网 readiness、PDF live OCR、统一考勤检查、移动矩阵和企业微信 UA/渲染矩阵均通过。显式企微打卡同步检查返回 `SKIP wecom_checkin_sync_disabled`。实际公网 `payment_batch_v1=force_off`,与 FIN-PROD-ENABLE 记录的正式常开口径不一致,因此结论为 `PAUSE_FOR_FIX`;runner 已跳过付款批次写入,未切换 flag、未部署、未接真实支付或 ERP。
|
- LAUNCH-AUTO-OPS-V1 是 ATTV1 后的正式启用自动化收口合同:新增当前正式状态 QA-only runner、live feature flag 只读检查和统一考勤 live 检查;本地门禁、公网 readiness、PDF live OCR、统一考勤检查、移动矩阵和企业微信 UA/渲染矩阵均通过。显式企微打卡同步检查返回 `SKIP wecom_checkin_sync_disabled`。实际公网 `payment_batch_v1=force_off`,与 FIN-PROD-ENABLE 记录的正式常开口径不一致,因此结论为 `PAUSE_FOR_FIX`;runner 已跳过付款批次写入,未切换 flag、未部署、未接真实支付或 ERP。
|
||||||
- AUTO-REMAINING-CLOSEOUT 是 LAUNCH-AUTO-OPS-V1 后的剩余自动化收口合同:已受控恢复公网 `payment_batch_v1=force_on` 并保持 `erp_sync=force_off`,复跑正式启用后自动巡检通过;`LAUNCHAUTO` marker 测试报销 `BX-20260517-0027` 完成付款批次、回单、AP 导出和审计闭环;公网移动矩阵 6 passed,企业微信渲染矩阵 8 passed。企微打卡 live 同步仍因外部配置记录 `SKIP wecom_checkin_sync_disabled`,不影响 OA 统一考勤入口完成态。
|
- AUTO-REMAINING-CLOSEOUT 是 LAUNCH-AUTO-OPS-V1 后的剩余自动化收口合同:已受控恢复公网 `payment_batch_v1=force_on` 并保持 `erp_sync=force_off`,复跑正式启用后自动巡检通过;`LAUNCHAUTO` marker 测试报销 `BX-20260517-0027` 完成付款批次、回单、AP 导出和审计闭环;公网移动矩阵 6 passed,企业微信渲染矩阵 8 passed。企微打卡 live 同步仍因外部配置记录 `SKIP wecom_checkin_sync_disabled`,不影响 OA 统一考勤入口完成态。
|
||||||
|
- HR-WECOM-DIRECTORY-V1 是 HRWD 当前合同:ERP 从冻结待办改为未来不接入 OA;人事生命周期和考勤同属 HR 大域但分模块实现;OA 为员工、权限、审批和审计主事实源,企业微信作为通讯录、入口、通知和打卡采集基础设施。新增 `hr_wecom_directory_sync_v1`、企业微信通讯录同步记录、入职创建/调岗更新/离职禁用和管理员重试;缺通讯录 Secret 或 API 权限时 OA 人事闭环照常完成,同步记录为可解释 SKIP/失败。
|
||||||
|
|
||||||
## 当前活跃治理流
|
## 当前活跃治理流
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
|||||||
| `ATTV1` | `issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv` | 已收口:考勤统一入口与企微打卡同步 V1 已部署并复检通过 | 关闭历史 OA 自建打卡;新增企微打卡摘要同步、补卡/外出/加班 OA 修正审批、月度解释页和移动入口;公网移动/企微矩阵 14 passed | 不用企业微信原生审批;不重造定位/人脸/Wi-Fi/设备风控;不保存完整位置、原始 payload 或人脸信息 |
|
| `ATTV1` | `issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv` | 已收口:考勤统一入口与企微打卡同步 V1 已部署并复检通过 | 关闭历史 OA 自建打卡;新增企微打卡摘要同步、补卡/外出/加班 OA 修正审批、月度解释页和移动入口;公网移动/企微矩阵 14 passed | 不用企业微信原生审批;不重造定位/人脸/Wi-Fi/设备风控;不保存完整位置、原始 payload 或人脸信息 |
|
||||||
| `LAUNCH-AUTO-OPS-V1` | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv` | 阶段收口:自动化完成,结论 `PAUSE_FOR_FIX` | 正式启用后自动 runner、live flag 只读校验、考勤统一入口 live 检查、移动/企微矩阵和证据回填;报告 `artifacts/qa-flow/launch-auto-ops-20260517-175548/` | 不切换正式 flag、不部署、不接真实支付、不接 ERP;`payment_batch_v1` 当前 force_off 漂移需单独决策恢复 |
|
| `LAUNCH-AUTO-OPS-V1` | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv` | 阶段收口:自动化完成,结论 `PAUSE_FOR_FIX` | 正式启用后自动 runner、live flag 只读校验、考勤统一入口 live 检查、移动/企微矩阵和证据回填;报告 `artifacts/qa-flow/launch-auto-ops-20260517-175548/` | 不切换正式 flag、不部署、不接真实支付、不接 ERP;`payment_batch_v1` 当前 force_off 漂移需单独决策恢复 |
|
||||||
| `AUTO-REMAINING-CLOSEOUT` | `issues/2026-05-17_19-30-44-auto-remaining-closeout.csv` | 已收口:剩余自动化运营门禁通过,结论 `GO_TO_OPERATE` | 恢复正式 `payment_batch_v1=force_on`、复跑 `run-launch-auto-ops.sh`、付款批次常开闭环、移动/企微矩阵、企微同步 SKIP 证据;报告 `artifacts/qa-flow/launch-auto-ops-20260517-193317/` | 不部署、不改业务代码、不接真实支付、不接 ERP、不开放图片 OCR、不导入银行流水;企微打卡同步外部配置仍需后台配置 |
|
| `AUTO-REMAINING-CLOSEOUT` | `issues/2026-05-17_19-30-44-auto-remaining-closeout.csv` | 已收口:剩余自动化运营门禁通过,结论 `GO_TO_OPERATE` | 恢复正式 `payment_batch_v1=force_on`、复跑 `run-launch-auto-ops.sh`、付款批次常开闭环、移动/企微矩阵、企微同步 SKIP 证据;报告 `artifacts/qa-flow/launch-auto-ops-20260517-193317/` | 不部署、不改业务代码、不接真实支付、不接 ERP、不开放图片 OCR、不导入银行流水;企微打卡同步外部配置仍需后台配置 |
|
||||||
|
| `HR-WECOM-DIRECTORY-V1` | `issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv` | 已收口:人事生命周期与企业微信通讯录同步闭环已部署公网 | ERP 永不接入 OA;入职/调岗/资料变更/离职驱动企微通讯录创建、更新、禁用;员工管理和详情展示同步状态与重试;部署后 live smoke、HRWD 端点 SKIP 检查、公网移动/企微矩阵通过;seed 已改为不覆盖正式 flag | 不使用企业微信原生审批、不接真实支付、不开放图片 OCR、不导入银行流水;企微通讯录真实写入依赖后台 Secret、默认部门和 API 权限 |
|
||||||
|
|
||||||
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
|
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
|
||||||
|
|
||||||
@@ -162,7 +164,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
|||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [架构概览](./架构概览.md) | 当前架构和运行时边界 |
|
| [架构概览](./架构概览.md) | 当前架构和运行时边界 |
|
||||||
| [集成边界](./集成边界.md) | ERP、IM、AI、MCP、PWA 集成边界;不扩大当前 MVP 范围 |
|
| [集成边界](./集成边界.md) | 历史 ERP/IM/AI/MCP/PWA 集成边界;HRWD 后 ERP 未来不接入 OA,不扩大当前 MVP 范围 |
|
||||||
| [领域事件清单](./领域事件清单.md) | 领域事件名和对外事件边界 |
|
| [领域事件清单](./领域事件清单.md) | 领域事件名和对外事件边界 |
|
||||||
| [MCP Tools](./mcp-tools.md) | 脚本和 AGENTS 强引用,保留英文文件名 |
|
| [MCP Tools](./mcp-tools.md) | 脚本和 AGENTS 强引用,保留英文文件名 |
|
||||||
| [非功能基线](./ops/非功能基线.md) | 安全、性能、回滚、上线最低要求 |
|
| [非功能基线](./ops/非功能基线.md) | 安全、性能、回滚、上线最低要求 |
|
||||||
|
|||||||
+7
-6
@@ -83,7 +83,7 @@
|
|||||||
| 当前已实现 / 已收口 | R6 小范围试运行主链路;PC6/PA6/FL6/AP6/RI6/LP6;ME6 自动化、公网和代表性真机门禁;MIF7/MIF7T 移动信息架构与真机反馈整改;MIF7TD 公网部署、匿名复检和带凭据 live smoke/OCR。 |
|
| 当前已实现 / 已收口 | R6 小范围试运行主链路;PC6/PA6/FL6/AP6/RI6/LP6;ME6 自动化、公网和代表性真机门禁;MIF7/MIF7T 移动信息架构与真机反馈整改;MIF7TD 公网部署、匿名复检和带凭据 live smoke/OCR。 |
|
||||||
| R7 必需 | 用户确认参与人、角色、时间窗口、回滚联系人、问题登记负责人和每日收口节奏;R7E 公网健康、public bundle、带凭据 live readiness、公网移动矩阵、核心业务流和账号角色只读门禁通过;阻断问题为 0。 |
|
| R7 必需 | 用户确认参与人、角色、时间窗口、回滚联系人、问题登记负责人和每日收口节奏;R7E 公网健康、public bundle、带凭据 live readiness、公网移动矩阵、核心业务流和账号角色只读门禁通过;阻断问题为 0。 |
|
||||||
| R7 后优化 | UX8 真实反馈池、iPhone 12 间距观察项、详情字段扫读性二次优化、移动采购创建能力评估、组织账号主数据治理、图片 OCR 能力、转审扩展和邮件通道完整验收。 |
|
| R7 后优化 | UX8 真实反馈池、iPhone 12 间距观察项、详情字段扫读性二次优化、移动采购创建能力评估、组织账号主数据治理、图片 OCR 能力、转审扩展和邮件通道完整验收。 |
|
||||||
| 明确不做 / 当前冻结 | ERP 实时集成、AI 自动处理、完整差旅、复杂 BPM、审批设计器、预算重型控制、企业微信原生审批;任何一项启动都必须另建里程碑和 Issue CSV。 |
|
| 明确不做 / 当前冻结 | ERP 未来不接入 OA;AI 自动处理、完整差旅、复杂 BPM、审批设计器、预算重型控制、企业微信原生审批继续不进入当前路线;任何一项业务扩张都必须另建里程碑和 Issue CSV。 |
|
||||||
|
|
||||||
### 2.3 P2T 候选优先级
|
### 2.3 P2T 候选优先级
|
||||||
|
|
||||||
@@ -91,13 +91,13 @@ P2T 只做排序和边界确认,不进入当前实现。候选项按 R7 真实
|
|||||||
|
|
||||||
| 优先级 | 候选 | 当前边界 |
|
| 优先级 | 候选 | 当前边界 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| P2-1 | 组织与账号主数据治理 | 优先覆盖直属主管缺失、主管/审批人停用、admin-only 误用、财务/管理员角色混淆、新员工账号和企业微信绑定;不直接写生产账号数据 |
|
| P2-1 | 人事生命周期与账号主数据治理 | 优先覆盖入职、转正、调岗、离职、直属主管缺失、主管/审批人停用、admin-only 误用、财务/管理员角色混淆、OA 账号和企业微信通讯录/绑定闭环;不使用企业微信原生审批 |
|
||||||
| P2-2 | 审批与通知扩展 | 转审扩展到请假/采购/差旅拆 `W2F`;邮件通道和三渠道完整验收另决策;不引入复杂 BPM |
|
| P2-2 | 审批与通知扩展 | 转审扩展到请假/采购/差旅拆 `W2F`;邮件通道和三渠道完整验收另决策;不引入复杂 BPM |
|
||||||
| P2-3 | OCR 与票据增强 | 当前试运行继续 PDF-only;图片 OCR、批量样票和异步体验需另补样票、隐私处理和 provider 回归 |
|
| P2-3 | OCR 与票据增强 | 当前试运行继续 PDF-only;图片 OCR、批量样票和异步体验需另补样票、隐私处理和 provider 回归 |
|
||||||
| P2-4 | 费用/预算/项目轻量增强 | 可评估费用提示和项目关联增强;预算重型控制、ERP 实时写回和正式成本核算继续冻结 |
|
| P2-4 | 费用/预算/项目轻量增强 | 可评估费用提示和项目关联增强;预算重型控制和正式成本核算继续冻结,ERP 不再作为后续接入项 |
|
||||||
| P2-5 | 运维上线增强 | 备份恢复演练、SLO 门禁和正式上线准备需单独窗口;不在 R7E 预检中做真实恢复 |
|
| P2-5 | 运维上线增强 | 备份恢复演练、SLO 门禁和正式上线准备需单独窗口;不在 R7E 预检中做真实恢复 |
|
||||||
|
|
||||||
冻结项不是永久删除,而是当前 MVP/R7 不启动:ERP 实时集成、AI 自动处理、完整差旅、复杂 BPM、审批设计器、预算重型控制、企业微信原生审批。
|
ERP 已从“冻结待办”调整为“未来不接入 OA”。其他冻结项不是永久删除,而是当前 MVP/R7 不启动:AI 自动处理、完整差旅、复杂 BPM、审批设计器、预算重型控制、企业微信原生审批。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -120,12 +120,13 @@ P2T 只做排序和边界确认,不进入当前实现。候选项按 R7 真实
|
|||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| P0 | 统一审批中心 | 所有正式审批在 OA 内处理 | PC 审批待办、移动审批 | 能提交、审批、驳回、退回、转审、留痕 |
|
| P0 | 统一审批中心 | 所有正式审批在 OA 内处理 | PC 审批待办、移动审批 | 能提交、审批、驳回、退回、转审、留痕 |
|
||||||
| P0 | 企业微信基础设施 | 手机入口、免登、通知、打卡原始采集 | 企业微信自建应用 `海音达工作台` | 手机进入 `/mobile`,通知 deeplink 回 OA |
|
| P0 | 企业微信基础设施 | 手机入口、免登、通知、打卡原始采集 | 企业微信自建应用 `海音达工作台` | 手机进入 `/mobile`,通知 deeplink 回 OA |
|
||||||
|
| P0 | 人事生命周期 | 入职、转正、调岗、离职、OA 账号、权限和企业微信通讯录状态闭环 | 人员管理、员工档案 | OA 为员工主事实源,企微通讯录同步成功或可解释 SKIP/失败可重试 |
|
||||||
| P0 | 报销/发票/OCR | 员工自助报销,财务可查重和导出 | 报销、移动报销、票据中心 | 上传、OCR、查重、绑定、审批、付款闭环 |
|
| P0 | 报销/发票/OCR | 员工自助报销,财务可查重和导出 | 报销、移动报销、票据中心 | 上传、OCR、查重、绑定、审批、付款闭环 |
|
||||||
| P0 | 请假与考勤修正审批 | 请假、补卡、外出、加班等修正都在 OA 内审批 | 请假、移动审批、团队状态 | 审批后团队状态和考勤口径不破坏 |
|
| P0 | 请假与考勤修正审批 | 请假、补卡、外出、加班等修正都在 OA 内审批 | 请假、移动审批、团队状态 | 审批后团队状态和考勤口径不破坏 |
|
||||||
| P1 | 采购/费用审批 | 小团队采购和费用事项留痕 | 采购、审批待办 | 申请、附件、审批、状态更新可用 |
|
| P1 | 采购/费用审批 | 小团队采购和费用事项留痕 | 采购、审批待办 | 申请、附件、审批、状态更新可用 |
|
||||||
| P1 | 合同与附件归档 | 管合同编号、责任人、归档位置和非涉密附件 | 合同/项目资料或项目页 | 能登记、查询、关联项目和审批记录 |
|
| P1 | 合同与附件归档 | 管合同编号、责任人、归档位置和非涉密附件 | 合同/项目资料或项目页 | 能登记、查询、关联项目和审批记录 |
|
||||||
| P1 | 项目轻管理 | 把费用、采购、差旅、周报挂到项目 | 项目、Portal、周报 | 能看项目事项、风险、费用摘要,不做重型排产 |
|
| P1 | 项目轻管理 | 把费用、采购、差旅、周报挂到项目 | 项目、Portal、周报 | 能看项目事项、风险、费用摘要,不做重型排产 |
|
||||||
| P2 | 预算/支付/ERP/AI | 在试运行稳定后增强运营能力 | 管理员备用入口 | 另立 Issue CSV,不进入首批验收 |
|
| P2 | 预算/支付/AI | 在试运行稳定后按真实反馈增强运营能力 | 管理员备用入口 | 另立 Issue CSV,不进入首批验收;ERP 不进入路线 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -406,7 +407,7 @@ REQAUD 复核后,ATTV1 已按“企业微信打卡、OA 审批解释”口径
|
|||||||
- 企业微信打卡 live 同步配置和权限稳定化。
|
- 企业微信打卡 live 同步配置和权限稳定化。
|
||||||
- 补卡、外出、加班的真实使用反馈体验优化。
|
- 补卡、外出、加班的真实使用反馈体验优化。
|
||||||
- 合同台账、合同编号、合同审批和离线档案号的细化。
|
- 合同台账、合同编号、合同审批和离线档案号的细化。
|
||||||
- 预算控制、支付批次、AP 运营和 ERP 出站凭证增强。
|
- 预算控制和付款/AP 运营真实使用反馈优化;ERP 不再规划出站凭证或实时集成。
|
||||||
- 项目轻管理与周报、待办、费用、采购、差旅的进一步联动。
|
- 项目轻管理与周报、待办、费用、采购、差旅的进一步联动。
|
||||||
- 全量 E2E 稳定性清理和移动视觉回归。
|
- 全量 E2E 稳定性清理和移动视觉回归。
|
||||||
- 生产备份恢复演练、并发试运行和正式上线复核。
|
- 生产备份恢复演练、并发试运行和正式上线复核。
|
||||||
|
|||||||
+21
-18
@@ -1,11 +1,11 @@
|
|||||||
# hyb-oa 管理员手册(10 人以下团队版)
|
# hyb-oa 管理员手册(10 人以下团队版)
|
||||||
|
|
||||||
**当前状态(2026-05-17 / SYSTEM-COMPLETE-GATE)**:R7E、PRELAUNCH、CASHIER-VOUCHER-V1、LAUNCH-DEPLOY-FINANCE、FIN-PROD-ENABLE 和 ACCOUNTING-HANDOFF-V1 均已收口;SYSTEM-COMPLETE-GATE 结论为 `GO_TO_USE`。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。
|
**当前状态(2026-05-17 / HRWD)**:R7E、PRELAUNCH、CASHIER-VOUCHER-V1、LAUNCH-DEPLOY-FINANCE、FIN-PROD-ENABLE、ACCOUNTING-HANDOFF-V1、ATTV1 和 HR-WECOM-DIRECTORY-V1 均已收口;SYSTEM-COMPLETE-GATE 结论为 `GO_TO_USE`。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。
|
||||||
|
|
||||||
## 1. 适用范围
|
## 1. 适用范围
|
||||||
- 适用于 `small_team_internal` 工作区模式。
|
- 适用于 `small_team_internal` 工作区模式。
|
||||||
- 适用于单公司、少部门、全员账号密码登录的小团队。
|
- 适用于单公司、少部门、全员账号密码登录的小团队。
|
||||||
- 目标是把内部高频管理动作收口到 `hyb-oa`;企业微信只作为入口、身份、通知和打卡底座。
|
- 目标是把内部高频管理动作收口到 `hyb-oa`;企业微信只作为入口、通讯录账号、身份、通知和打卡底座。
|
||||||
|
|
||||||
## 2. 管理员每周固定动作
|
## 2. 管理员每周固定动作
|
||||||
- 周一:查看 `审批待办`、报销/OCR 异常、请假/补卡/外出/加班审批、项目风险和本周关键付款/归档事项。
|
- 周一:查看 `审批待办`、报销/OCR 异常、请假/补卡/外出/加班审批、项目风险和本周关键付款/归档事项。
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
## 3. 首次上线最小配置
|
## 3. 首次上线最小配置
|
||||||
1. 进入 `工作区设置`,确认产品模式为 `small_team_internal`。
|
1. 进入 `工作区设置`,确认产品模式为 `small_team_internal`。
|
||||||
2. 首批主路径优先保留:`Portal`、`审批待办`、`报销`、`票据/OCR`、`请假`、`项目`、`采购/费用`、`合同/附件`。
|
2. 首批主路径优先保留:`Portal`、`审批待办`、`报销`、`票据/OCR`、`请假`、`项目`、`采购/费用`、`合同/附件`。
|
||||||
3. `团队状态`、`周报`、`待办`、`公告`、`人员`、`资产` 可按真实试运行需要开启;`支付批次` 和 `AP 运营` 已作为财务闭环正式能力面向出纳、财务和管理员开放,普通员工不显示该入口;`预算`、`ERP`、`AI`、完整差旅能力仍作为备用或历史能力。
|
3. `团队状态`、`周报`、`待办`、`公告`、`人员`、`资产` 可按真实试运行需要开启;`支付批次` 和 `AP 运营` 已作为财务闭环正式能力面向出纳、财务和管理员开放,普通员工不显示该入口;`预算`、`AI`、完整差旅能力仍作为备用或历史能力,ERP 不再规划接入。
|
||||||
4. 在 `用户管理` 中按推荐预设配置:老板 / 管理员 / 主管 / 员工。
|
4. 在 `用户管理` 中按推荐预设配置:老板 / 管理员 / 主管 / 员工。
|
||||||
- 正式审批组织口径:`majianyong` 配置为员工可见直属主管和 `manager`;`wangshunli` 配置为后台公司负责人 `ceo + admin`,不配置为普通 `manager`。
|
- 正式审批组织口径:`majianyong` 配置为员工可见直属主管和 `manager`;`wangshunli` 配置为后台公司负责人 `ceo + admin`,不配置为普通 `manager`。
|
||||||
- 普通员工和专用测试员工的直属主管配置为 `majianyong`;`majianyong` 的直属领导配置为 `wangshunli`。
|
- 普通员工和专用测试员工的直属主管配置为 `majianyong`;`majianyong` 的直属领导配置为 `wangshunli`。
|
||||||
@@ -23,12 +23,12 @@
|
|||||||
- 缺直属主管、主管停用、主管链断裂或审批节点解析到申请人本人时,应先修正组织或审批规则,不要用管理员账号代批。
|
- 缺直属主管、主管停用、主管链断裂或审批节点解析到申请人本人时,应先修正组织或审批规则,不要用管理员账号代批。
|
||||||
5. 检查功能开关:
|
5. 检查功能开关:
|
||||||
- 建议开启:`portal_home_v1`、企业微信入口相关开关、报销/OCR、审批待办、请假、项目基础能力和 `payment_batch_v1`。
|
- 建议开启:`portal_home_v1`、企业微信入口相关开关、报销/OCR、审批待办、请假、项目基础能力和 `payment_batch_v1`。
|
||||||
- 按需开启:`announcements_v1`、`presence_board_v1`、`weekly_report_v1`、`employee_lifecycle_v1`、`todo_board_v1`、`global_search_v1`。
|
- 按需开启:`announcements_v1`、`presence_board_v1`、`weekly_report_v1`、`employee_lifecycle_v1`、`hr_wecom_directory_sync_v1`、`todo_board_v1`、`global_search_v1`。
|
||||||
- 建议保留备用:`cutover_console_v1`、预算、ERP、AI 和完整差旅相关开关。
|
- 建议保留备用:`cutover_console_v1`、预算、AI 和完整差旅相关开关;`erp_sync` 保持关闭且不再作为后续接入目标。
|
||||||
|
|
||||||
### 3.1 ORG8 组织账号与角色边界
|
### 3.1 ORG8 组织账号与角色边界
|
||||||
|
|
||||||
OA 员工账号是权限、审批、业务数据和审计的主事实源;企业微信身份只作为入口、免登和通知辅助。管理员维护组织账号时,先保证员工主数据完整,再配置角色和企业微信绑定。
|
OA 员工账号是权限、审批、业务数据和审计的主事实源;企业微信作为通讯录账号、入口、免登、通知和打卡采集基础设施。管理员维护组织账号时,先保证 OA 员工主数据完整,再配置角色、企业微信通讯录同步和绑定状态。
|
||||||
|
|
||||||
最小必填主数据:
|
最小必填主数据:
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ OA 员工账号是权限、审批、业务数据和审计的主事实源;企
|
|||||||
| 直属主管 | 请假、报销、采购等默认审批链 | 审批人缺失或错误回退 |
|
| 直属主管 | 请假、报销、采购等默认审批链 | 审批人缺失或错误回退 |
|
||||||
| 账号状态 | 登录、入口、通知和禁用拦截 | 离职或停用人员可能继续进入 |
|
| 账号状态 | 登录、入口、通知和禁用拦截 | 离职或停用人员可能继续进入 |
|
||||||
| 业务角色 | 审批、财务、HR、系统管理入口 | 权限过大或无法处理待办 |
|
| 业务角色 | 审批、财务、HR、系统管理入口 | 权限过大或无法处理待办 |
|
||||||
| 企业微信绑定状态 | 手机入口、免登、通知 deeplink | 企业微信入口无法直达或误绑定 |
|
| 企业微信通讯录/绑定状态 | 手机入口、免登、通知 deeplink、打卡采集身份 | 企业微信入口无法直达、误绑定或离职后未禁用 |
|
||||||
|
|
||||||
角色边界:
|
角色边界:
|
||||||
|
|
||||||
@@ -57,8 +57,8 @@ OA 员工账号是权限、审批、业务数据和审计的主事实源;企
|
|||||||
- `admin-only` 账号不得作为报销、请假或采购的业务审批人。
|
- `admin-only` 账号不得作为报销、请假或采购的业务审批人。
|
||||||
- 财务和系统管理员混授属于高风险组合,必须有人工确认、审计记录和可回退路径。
|
- 财务和系统管理员混授属于高风险组合,必须有人工确认、审计记录和可回退路径。
|
||||||
- 直属主管、审批人或财务账号停用后,不得继续接收新待办;历史审批记录保留只读。
|
- 直属主管、审批人或财务账号停用后,不得继续接收新待办;历史审批记录保留只读。
|
||||||
- 新员工账号创建必须同时确认直属主管、初始角色、账号状态和企业微信绑定路径。
|
- 新员工账号创建必须同时确认直属主管、初始角色、账号状态、企业微信通讯录同步状态和绑定路径。
|
||||||
- 离职或停用账号必须阻断登录、企业微信入口和新业务处理,但不得删除历史单据和审批记录。
|
- 离职或停用账号必须阻断登录、企业微信入口和新业务处理;离职完成后默认禁用企业微信成员,不删除成员,不删除历史单据和审批记录。
|
||||||
|
|
||||||
只读审计口径:
|
只读审计口径:
|
||||||
|
|
||||||
@@ -236,24 +236,27 @@ ORDER BY e.employee_no;
|
|||||||
- `payment_batch_v1` 意外关闭,或 `erp_sync`、图片 OCR、真实支付、银行流水导入被误开启。
|
- `payment_batch_v1` 意外关闭,或 `erp_sync`、图片 OCR、真实支付、银行流水导入被误开启。
|
||||||
- 同一天出现多个高优先级问题且无法解释清楚。
|
- 同一天出现多个高优先级问题且无法解释清楚。
|
||||||
|
|
||||||
首周不主动新增可选功能。UX8 只接真实反馈或自动化确定性失败;移动采购创建、通知扩展、合同/供应商/资产、图片 OCR、ERP、真实支付、银行流水导入、复杂 BPM、审批设计器和重预算都必须另建独立合同。
|
首周不主动新增可选功能。UX8 只接真实反馈或自动化确定性失败;移动采购创建、通知扩展、合同/供应商/资产、图片 OCR、真实支付、银行流水导入、复杂 BPM、审批设计器和重预算都必须另建独立合同;ERP 不再作为后续合同候选。
|
||||||
|
|
||||||
## 4. 人员生命周期管理
|
## 4. 人员生命周期管理
|
||||||
### 4.1 入职
|
### 4.1 入职
|
||||||
1. 在 `人员管理` 创建员工账号。
|
1. 在 `人员管理` 创建员工账号。
|
||||||
2. 打开员工详情页,补齐部门、职位、直属上级、试用期结束日、工作地点、紧急联系人。
|
2. 打开员工详情页,补齐部门、职位、直属上级、试用期结束日、工作地点、紧急联系人。
|
||||||
3. 发起 `入职`,系统会自动生成 checklist:账号初始化、资产发放、制度确认。
|
3. 若已开启 `hr_wecom_directory_sync_v1` 且企业微信通讯录配置完整,创建或发起入职时系统会尝试创建企业微信成员;缺 Secret、缺默认部门或无通讯录权限时记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`,OA 入职不回滚。
|
||||||
4. checklist 全部完成后,员工状态自动从 `onboarding` 进入 `probation`。
|
4. 发起 `入职`,系统会自动生成 checklist:账号初始化、资产发放、制度确认。
|
||||||
|
5. checklist 全部完成后,员工状态自动从 `onboarding` 进入 `probation`。
|
||||||
|
6. 人员列表或员工详情中的“企业微信通讯录”状态若为同步失败或配置缺失,管理员可在配置修复后执行“重试同步”。
|
||||||
|
|
||||||
### 4.2 转正 / 调岗
|
### 4.2 转正 / 调岗
|
||||||
- `转正`:记录事件并将员工状态改为 `active`。
|
- `转正`:记录事件并将员工状态改为 `active`。
|
||||||
- `调岗`:记录部门、直属上级、职位调整,同时生成调岗交接 checklist。
|
- `调岗`:记录部门、直属上级、职位调整,同时生成调岗交接 checklist;若通讯录同步已开启,会尝试更新企业微信成员资料。
|
||||||
- 这两个动作只做管理留痕,不引入复杂审批流。
|
- 这两个动作只做管理留痕,不引入复杂审批流。
|
||||||
|
|
||||||
### 4.3 离职
|
### 4.3 离职
|
||||||
1. 发起 `离职` 后,系统自动生成离职 checklist:资产归还、权限回收、费用与审批清理、交接说明。
|
1. 发起 `离职` 后,系统自动生成离职 checklist:资产归还、权限回收、费用与审批清理、交接说明。
|
||||||
2. 若员工仍有名下资产或待处理审批任务,系统不会自动完成离职。
|
2. 若员工仍有名下资产或待处理审批任务,系统不会自动完成离职。
|
||||||
3. checklist 完成且阻塞清零后,账号会被停用,主业务入口对该账号不可再用。
|
3. checklist 完成且阻塞清零后,OA 账号会被停用并吊销现有会话,主业务入口对该账号不可再用。
|
||||||
|
4. 离职完成后系统默认尝试禁用企业微信成员,不删除企业微信成员;同步失败时保留记录并允许管理员重试。
|
||||||
|
|
||||||
## 5. 团队状态与考勤口径
|
## 5. 团队状态与考勤口径
|
||||||
- 员工每天可在 `Portal` 或 `团队` 页更新状态:在岗 / 居家 / 外出 / 出差。
|
- 员工每天可在 `Portal` 或 `团队` 页更新状态:在岗 / 居家 / 外出 / 出差。
|
||||||
@@ -283,7 +286,7 @@ ORDER BY e.employee_no;
|
|||||||
## 8. 备用模块治理
|
## 8. 备用模块治理
|
||||||
- 采购/费用、合同/附件和项目轻管理属于 P1 轻量能力,可在 P0 审批、报销/OCR、请假和企业微信入口稳定后逐步开放。
|
- 采购/费用、合同/附件和项目轻管理属于 P1 轻量能力,可在 P0 审批、报销/OCR、请假和企业微信入口稳定后逐步开放。
|
||||||
- 支付批次和 AP 运营已作为正式财务闭环开放给出纳、财务和管理员;普通员工仍不显示财务运营入口。
|
- 支付批次和 AP 运营已作为正式财务闭环开放给出纳、财务和管理员;普通员工仍不显示财务运营入口。
|
||||||
- 预算、ERP、AI、完整差旅、资产深度管理等保留代码和历史数据,但不作为当前 MVP 首批主路径。
|
- 预算、AI、完整差旅、资产深度管理等保留代码和历史数据,但不作为当前 MVP 首批主路径;ERP 保持关闭,不再规划接入。
|
||||||
- 若短期完全不用,保持管理员可见即可,不必删除。
|
- 若短期完全不用,保持管理员可见即可,不必删除。
|
||||||
- 只有出现真实场景时,再恢复到主导航或单独扩展。
|
- 只有出现真实场景时,再恢复到主导航或单独扩展。
|
||||||
|
|
||||||
@@ -293,7 +296,7 @@ ORDER BY e.employee_no;
|
|||||||
3. 同步启用付款批次、AP 台账和银行回单附件,付款动作仍由出纳在 OA 外通过银行渠道完成。
|
3. 同步启用付款批次、AP 台账和银行回单附件,付款动作仍由出纳在 OA 外通过银行渠道完成。
|
||||||
4. 同步启用已完成的 `请假`、补卡、外出、加班 OA 审批和月度考勤解释;企业微信打卡仍作为原始采集和风控底座。
|
4. 同步启用已完成的 `请假`、补卡、外出、加班 OA 审批和月度考勤解释;企业微信打卡仍作为原始采集和风控底座。
|
||||||
5. 在 P0 稳定后开放 P1:`采购/费用审批`、`合同/附件归档`、`项目轻管理`。
|
5. 在 P0 稳定后开放 P1:`采购/费用审批`、`合同/附件归档`、`项目轻管理`。
|
||||||
6. 最后按真实需要评估周报、团队状态、公告、人员生命周期、资产、预算、ERP、AI 和完整差旅。
|
6. 最后按真实需要评估周报、团队状态、公告、人员生命周期、资产、预算、AI 和完整差旅;ERP 不进入评估队列。
|
||||||
|
|
||||||
## 10. 试点验收口径
|
## 10. 试点验收口径
|
||||||
- 全员都能账号密码登录;已绑定员工能从企业微信 `海音达工作台` 进入移动工作台。
|
- 全员都能账号密码登录;已绑定员工能从企业微信 `海音达工作台` 进入移动工作台。
|
||||||
@@ -303,7 +306,7 @@ ORDER BY e.employee_no;
|
|||||||
- 至少 1 个审批待办从企业微信提醒 deeplink 回到 OA 页面处理。
|
- 至少 1 个审批待办从企业微信提醒 deeplink 回到 OA 页面处理。
|
||||||
- P1 试点时,至少 1 条采购/费用申请、1 条合同台账、1 个项目关联事项可创建、审批或归档。
|
- P1 试点时,至少 1 条采购/费用申请、1 条合同台账、1 个项目关联事项可创建、审批或归档。
|
||||||
- 正式使用前至少完成一次备份可用性核对;真实恢复演练必须另建批准合同,并记录恢复后的 `/ready` 结果。
|
- 正式使用前至少完成一次备份可用性核对;真实恢复演练必须另建批准合同,并记录恢复后的 `/ready` 结果。
|
||||||
- 普通员工主导航不出现预算、AP、ERP、AI、完整差旅等当前首批不开放的备用模块。
|
- 普通员工主导航不出现预算、AP、AI、完整差旅等当前首批不开放的备用模块;ERP 入口保持关闭。
|
||||||
|
|
||||||
## 11. 4 周观测操作法
|
## 11. 4 周观测操作法
|
||||||
1. 每周一进入 `Portal`、`审批待办` 和 `考勤` 查看近 7 天登录覆盖率、审批积压、报销/OCR 异常、请假/考勤修正审批和企业微信入口错误;企微打卡同步缺配置时记录为 `SKIP`。
|
1. 每周一进入 `Portal`、`审批待办` 和 `考勤` 查看近 7 天登录覆盖率、审批积压、报销/OCR 异常、请假/考勤修正审批和企业微信入口错误;企微打卡同步缺配置时记录为 `SKIP`。
|
||||||
@@ -383,7 +386,7 @@ ORDER BY e.employee_no;
|
|||||||
- `APP_ORIGIN=https://oa.example.com`
|
- `APP_ORIGIN=https://oa.example.com`
|
||||||
- `COOKIE_SECURE=true`
|
- `COOKIE_SECURE=true`
|
||||||
- `PUBLIC_SECURITY_HARDENING_ENABLED=true`
|
- `PUBLIC_SECURITY_HARDENING_ENABLED=true`
|
||||||
3. 若启用企业微信入口,补齐:`WECOM_*` 与 `WECOM_ALLOWED_RETURN_HOSTS`。
|
3. 若启用企业微信入口,补齐:`WECOM_*` 与 `WECOM_ALLOWED_RETURN_HOSTS`。若启用 OA 驱动企业微信通讯录同步,还需补齐 `WECOM_CONTACT_SYNC_ENABLED=true`、`WECOM_CONTACT_SECRET`、`WECOM_CONTACT_DEFAULT_DEPARTMENT_ID`,并确认该 Secret 具备通讯录写权限。
|
||||||
4. 将 `OPENAPI_DOCS_ENABLED` 保持为 `false`,不要在公网生产暴露文档入口。
|
4. 将 `OPENAPI_DOCS_ENABLED` 保持为 `false`,不要在公网生产暴露文档入口。
|
||||||
5. 定期检查审计日志中的登录失败、Refresh 来源不匹配与涉密资料查看记录。
|
5. 定期检查审计日志中的登录失败、Refresh 来源不匹配与涉密资料查看记录。
|
||||||
6. 准备独立 smoke 账号,不使用老板、管理员或真实员工日常账号跑自动化;smoke 账号的角色和直属上级应按正式审批链配置。
|
6. 准备独立 smoke 账号,不使用老板、管理员或真实员工日常账号跑自动化;smoke 账号的角色和直属上级应按正式审批链配置。
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
**快照日期**:2026-03-12
|
**快照日期**:2026-03-12
|
||||||
**当前基线**:`P19+IM-1`、`P19+ERP-1`、`P19+ERP-2`、`P19+ERP-3`、`P19+MCP-1`、`P19+AI-1`、`P20+TRAVEL-1`、`P20+TRAVEL-2`、`P20+TRAVEL-3`、`P21+PWA-1` 与 `P21+PWA-2` 已完成;当前运行时已落地企微群机器人 + 深链 + Outbox 异步派发的一期协同适配,以及 ERP 主数据 Pull、报销付款凭证切片、采购双阶段 journal / 归档摘要出站、资产台账摘要出站、Postgres MCP 只读工具基线、报销 / 采购 / 资产详情页 AI 只读建议挂件、差旅申请主单 / 行程段 / 审批预算锁定 / `travel_orders` 关联入站、混合双轨对公支付闭环与批次级对账摘要桥的三期差旅基线,以及浏览器 PWA 下的移动资产扫码、盘点快捷录入与离线队列重放、移动审批 deep link 直达、本地会话快照离线守卫与审批草稿重放。
|
**当前基线**:`P19+IM-1`、`P19+ERP-1`、`P19+ERP-2`、`P19+ERP-3`、`P19+MCP-1`、`P19+AI-1`、`P20+TRAVEL-1`、`P20+TRAVEL-2`、`P20+TRAVEL-3`、`P21+PWA-1` 与 `P21+PWA-2` 已完成;当前运行时已落地企微群机器人 + 深链 + Outbox 异步派发的一期协同适配,以及 ERP 主数据 Pull、报销付款凭证切片、采购双阶段 journal / 归档摘要出站、资产台账摘要出站、Postgres MCP 只读工具基线、报销 / 采购 / 资产详情页 AI 只读建议挂件、差旅申请主单 / 行程段 / 审批预算锁定 / `travel_orders` 关联入站、混合双轨对公支付闭环与批次级对账摘要桥的三期差旅基线,以及浏览器 PWA 下的移动资产扫码、盘点快捷录入与离线队列重放、移动审批 deep link 直达、本地会话快照离线守卫与审批草稿重放。
|
||||||
**目标**:在保持核心域稳定的前提下,冻结 ERP / IM / AI / MCP / TRAVEL / PWA 的长期边界,并记录 IM 一期、ERP 三期、AI 一期、差旅三期与 PWA 两期的已实现范围、禁入规则以及 R10 主线闭环后的固定收口结论。
|
**目标**:在保持核心域稳定的前提下,冻结 ERP / IM / AI / MCP / TRAVEL / PWA 的长期边界,并记录 IM 一期、ERP 三期、AI 一期、差旅三期与 PWA 两期的已实现范围、禁入规则以及 R10 主线闭环后的固定收口结论。
|
||||||
**当前路线说明**:2026-05-09 之后首批 MVP 固定为 `hyb-oa` 唯一审批中心 + 企业微信基础设施。飞书路线已废弃;钉钉、小程序、外部 IM 回调审批和付费第三方套件不进入首批。
|
**当前路线说明**:2026-05-17 HRWD 后,ERP 从“冻结待办”调整为“未来不接入 OA”;财务闭环固定为 OA 内审批/台账/付款批次/银行回单 + 月度代账交接。2026-05-09 之后首批 MVP 固定为 `hyb-oa` 唯一审批中心 + 企业微信基础设施。飞书路线已废弃;钉钉、小程序、外部 IM 回调审批和付费第三方套件不进入首批。
|
||||||
**不得误读**:本文是技术参考和历史证据,不作为当前 MVP 扩大 ERP、AI、TRAVEL、PWA 或外部 IM 适配范围的依据;任何重新启用都必须先更新 PRD 或模块规约并新建 Issue CSV。
|
**不得误读**:本文是技术参考和历史证据,不作为当前 MVP 扩大 ERP、AI、TRAVEL、PWA 或外部 IM 适配范围的依据;ERP 相关历史实现和接口不代表后续路线,不再规划对接外部 ERP。
|
||||||
|
|
||||||
## 1. 总体原则
|
## 1. 总体原则
|
||||||
|
|
||||||
|
|||||||
+18
-7
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
### 1.1 目标
|
### 1.1 目标
|
||||||
|
|
||||||
- 企业微信作为 `hyb-oa` 的手机工作台入口、身份辅助、消息通知和打卡原始采集底座。
|
- 企业微信作为 `hyb-oa` 的手机工作台入口、通讯录账号、身份辅助、消息通知和打卡原始采集底座。
|
||||||
- 员工在手机企业微信中进入唯一自建应用 `海音达工作台`,默认落到 OA 移动端。
|
- 员工在手机企业微信中进入唯一自建应用 `海音达工作台`,默认落到 OA 移动端。
|
||||||
- 企业微信消息只做提醒和跳转,审批动作、业务填写、附件查看和正式留痕都在 OA 内完成。
|
- 企业微信消息只做提醒和跳转,审批动作、业务填写、附件查看和正式留痕都在 OA 内完成。
|
||||||
- 企业微信打卡只作为原始采集来源,后续结合 OA 请假、补卡、外出、加班审批形成正式考勤口径。
|
- 企业微信打卡只作为原始采集来源,后续结合 OA 请假、补卡、外出、加班审批形成正式考勤口径。
|
||||||
@@ -35,11 +35,11 @@
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 员工 | 从手机进入 OA、接收提醒、完成绑定 | 进入移动工作台、绑定企业微信身份、点击消息 deeplink、查看本人相关业务 | 不能通过企业微信绕过 OA 权限 |
|
| 员工 | 从手机进入 OA、接收提醒、完成绑定 | 进入移动工作台、绑定企业微信身份、点击消息 deeplink、查看本人相关业务 | 不能通过企业微信绕过 OA 权限 |
|
||||||
| 审批人 | 在手机端收到待办提醒并回 OA 处理 | 点击通知进入 OA 审批页、在 OA 内审批 | 不能在企业微信原生审批里完成正式审批 |
|
| 审批人 | 在手机端收到待办提醒并回 OA 处理 | 点击通知进入 OA 审批页、在 OA 内审批 | 不能在企业微信原生审批里完成正式审批 |
|
||||||
| 管理员/HR | 管理入口、人员、绑定异常和离职停用 | 配置可见范围、协助绑定、处理离职停用、查看通知失败 | 不得手工把一个企业微信身份绑定到多个员工 |
|
| 管理员/HR | 管理入口、人员、通讯录同步、绑定异常和离职停用 | 配置可见范围、协助绑定、触发通讯录创建/更新/禁用、处理离职停用、查看通知失败 | 不得手工把一个企业微信身份绑定到多个员工;通讯录同步失败不得回滚 OA 人事主数据 |
|
||||||
| 系统管理员 | 维护企业微信应用配置和消息通道 | 配置 CorpID、AgentID、回调、可信域名、消息开关和重试策略 | 不处理业务审批,不暴露 secret |
|
| 系统管理员 | 维护企业微信应用配置和消息通道 | 配置 CorpID、AgentID、回调、可信域名、消息开关和重试策略 | 不处理业务审批,不暴露 secret |
|
||||||
| 财务/老板 | 接收关键待办或状态提醒 | 点击 deeplink 回 OA 查看或处理 | 消息中不得出现完整发票、合同正文、敏感报价、银行卡、身份证 |
|
| 财务/老板 | 接收关键待办或状态提醒 | 点击 deeplink 回 OA 查看或处理 | 消息中不得出现完整发票、合同正文、敏感报价、银行卡、身份证 |
|
||||||
|
|
||||||
企业微信身份只是登录和通知辅助。后端 OA 账号、角色、数据范围和审批任务仍是权限单一事实源。
|
OA 是员工、账号、角色、数据范围、审批任务和审计的主事实源。企业微信身份和通讯录账号服务于入口、通知、打卡采集和移动工作台,不替代 OA 权限判断。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -141,10 +141,21 @@ deeplink 必须校验目标地址白名单,避免跳转到非 OA 域名或未
|
|||||||
|
|
||||||
## 6. 通讯录身份边界
|
## 6. 通讯录身份边界
|
||||||
|
|
||||||
- 企业微信通讯录可作为员工姓名、部门、手机号、邮箱、在职状态的辅助来源。
|
- HRWD 起,OA 驱动企业微信通讯录成员创建、更新和禁用;OA 员工表仍是权限、审批、业务数据和审计的主事实源。
|
||||||
- OA 员工表仍是权限、审批、业务数据和审计的主事实源。
|
- 新员工在 OA 创建账号和入职记录后,可在 `hr_wecom_directory_sync_v1` 开启且配置完整时同步创建企业微信成员,并写入 `wecom_identity_bindings.external_user_id`。
|
||||||
- 首批不要求自动全量同步通讯录;可先支持管理员手工维护员工和绑定关系。
|
- OA 调岗、部门、职位、手机号、邮箱变化后,可触发企业微信成员更新;历史审批和历史业务单据不因调岗被改写。
|
||||||
- 后续若接通讯录同步,必须处理新增、改名、调岗、离职、手机号变更和部门变更,并保留同步日志。
|
- 离职完成后默认禁用企业微信成员,不删除成员;绑定关系和历史业务记录保留审计。
|
||||||
|
- 企业微信通讯录同步使用独立通讯录 Secret:`WECOM_CONTACT_SYNC_ENABLED`、`WECOM_CONTACT_SECRET`、`WECOM_CONTACT_DEFAULT_DEPARTMENT_ID`。不得默认复用应用消息 Secret,除非企业微信后台明确该 Secret 具备通讯录写权限。
|
||||||
|
- 缺 Secret、缺默认部门或缺通讯录 API 权限时,OA 人事流程照常完成,通讯录同步记录为 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG` 或失败可重试,不伪造成成功。
|
||||||
|
- 通讯录同步记录只保存 employeeId、脱敏 externalUserId、操作、状态、错误码、重试次数和脱敏摘要;不得保存 access token、Secret、完整手机号、原始 payload 或敏感身份信息。
|
||||||
|
|
||||||
|
当前完成度:
|
||||||
|
|
||||||
|
| 项目 | 状态 | 当前口径 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| OA 员工生命周期 | `DONE` | 支持入职、转正、调岗、离职 checklist、账号禁用、会话失效和审计。 |
|
||||||
|
| OA 驱动企微通讯录创建/更新/禁用 | `DONE_WITH_EXTERNAL_SKIP` | 代码、同步记录、员工管理状态和重试入口已部署;公网端点检查在缺配置时返回 `SKIP wecom_contact_sync_disabled`,真实写入取决于通讯录 Secret、默认部门和企业微信通讯录 API 权限。 |
|
||||||
|
| 企业微信原生审批 | `FROZEN` | 不作为正式审批流,入离职、请假、补卡、外出、加班审批均在 OA 内留痕。 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||||
|
"HRWD-0","合同建档与边界纠偏","建立 HR-WECOM-DIRECTORY-V1 合同,固定以后不接 ERP、人事生命周期与考勤分属 HR 大域下两个子模块、OA 为员工权限审批审计主事实源、企业微信为通讯录入口通知和打卡采集基础设施。","1) Issue CSV 校验通过;2) ERP 口径从当前冻结改为未来不接入;3) 人事生命周期和考勤边界写清;4) 不使用企业微信原生审批、不接真实支付、不开放图片 OCR、不导入银行流水。","受影响流程:产品边界 -> HRWD 合同 -> 后续实现;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv。","python3|manual","DONE","DONE","DONE","issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv","[]","回滚路径:回退本 CSV 和文档口径回写;不涉及运行时。"
|
||||||
|
"HRWD-1","现状审计","核对现有 employee_lifecycle_v1、员工创建、入职转正调岗离职、账号启停和企业微信绑定能力,确认当前缺口是 OA 驱动企业微信通讯录创建更新禁用。","1) 完成现状矩阵;2) 明确已完成、可复用、缺口和外部企微配置项;3) 不误写现有绑定为通讯录自动创建;4) 不打印真实账号敏感信息。","受影响流程:员工管理 -> 入离职 -> 企业微信绑定/通讯录缺口;验证步骤:rg -n 'employee_lifecycle_v1|onboarding|offboarding|wecomIdentityBinding|WECOM_CONTACT' server/src client/src docs issues;只读核对 server/src/routes/employees.js、server/src/routes/users.js、server/src/services/integrations。","rg|manual","DONE","DONE","DONE","server/src/routes/employees.js|server/src/routes/users.js|client/src/pages/PeoplePage.tsx|docs","[""HRWD-0""]","审计不修改生产数据;只输出脱敏结论。"
|
||||||
|
"HRWD-2","企业微信通讯录同步配置","新增 hr_wecom_directory_sync_v1 feature flag、仓库外配置 WECOM_CONTACT_SYNC_ENABLED/WECOM_CONTACT_SECRET/WECOM_CONTACT_DEFAULT_DEPARTMENT_ID,并保持默认关闭。","1) feature flag key、env gate、seed 默认值和示例 env 完整;2) 缺配置时返回 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;3) 不复用 WECOM_AGENT_SECRET 作为通讯录写权限 Secret;4) 配置不泄露。","受影响流程:管理员启用 HR 企微通讯录同步 -> 配置探测 -> 后续创建更新禁用;验证步骤:cd server && npm run prisma:validate;cd server && node --check src/config.js src/services/featureFlags/index.js src/routes/featureFlags.js。","node|prisma","DONE","DONE","DONE","server/src/config.js|server/src/services/featureFlags/index.js|server/src/routes/featureFlags.js|server/src/scripts/seed-config.js|server/.env.example|deploy/trialrun/.env.example","[""HRWD-1""]","默认 force_off;回滚路径:删除新增 flag/env 读取和 seed 记录。"
|
||||||
|
"HRWD-3","入职闭环与企微创建","在 OA 创建员工/入职流程中接入企业微信通讯录创建:配置完整且 flag 开启时创建企微成员并写入绑定;失败不回滚 OA 员工创建,记录可重试同步结果。","1) OA 员工创建仍可独立成功;2) 企微创建成功后有 wecom_identity_bindings;3) 企微失败记录同步失败并可重试;4) 日志和响应脱敏手机号、secret、token 和原始 payload。","受影响流程:HR 创建员工 -> OA 账号/角色 -> 入职 checklist -> 企微通讯录创建 -> 绑定;验证步骤:cd server && npm test -- tests/unit/routes/users.write.test.js tests/unit/routes/employees.wecomDirectory.test.js。","npm|node","DONE","DONE","DONE","server/src/routes/users.js|server/src/routes/employees.js|server/src/services/integrations/wecomDirectory.js|server/tests/unit/routes","[""HRWD-2""]","企业微信 API mock 优先;live 写入必须另有公网/后台配置批准。"
|
||||||
|
"HRWD-4","离职闭环与账号禁用","完善离职闭环:离职 checklist 完成后禁用 OA 账号、递增 token_version、默认禁用企业微信成员而非删除,并保留绑定和历史审计。","1) 离职启动生成 checklist;2) checklist 完成后员工 status=archived 且 token_version 递增;3) 企业微信成员禁用成功或记录失败可重试;4) 绑定关系和历史业务记录保留;5) 存在阻断项时给出可解释提示。","受影响流程:HR 发起离职 -> checklist -> 权限回收 -> OA 账号禁用 -> 企微成员禁用;验证步骤:cd server && npm test -- tests/unit/routes/employees.wecomDirectory.test.js tests/unit/routes/users.write.test.js tests/unit/routes/auth.login.test.js。","npm|node","DONE","DONE","DONE","server/src/routes/employees.js|server/src/services/employeeLifecycle.js|server/src/services/integrations/wecomDirectory.js|server/tests/unit/routes","[""HRWD-3""]","默认禁用不删除;回滚路径:回退离职钩子,历史数据不删除。"
|
||||||
|
"HRWD-5","调岗和资料变更同步","OA 调岗、部门、主管、职位、手机号、邮箱变化后生成企业微信成员更新;同步失败不回滚 OA 变更,进入可重试记录。","1) 调岗不影响历史审批和业务单据;2) 变更成功时企微更新记录为 success;3) 失败时记录 failed/skipped 且 OA 变更保留;4) 管理员可重试。","受影响流程:HR 调岗/修改员工资料 -> OA 主数据更新 -> 企微通讯录更新;验证步骤:cd server && npm test -- tests/unit/routes/employees.wecomDirectory.test.js tests/unit/routes/employees.patch.test.js。","npm|node","DONE","DONE","DONE","server/src/routes/employees.js|server/src/routes/users.js|server/src/services/integrations/wecomDirectory.js","[""HRWD-3""]","涉及外部系统最终一致性;不做分布式事务。"
|
||||||
|
"HRWD-6","同步记录权限审计","新增企业微信通讯录同步记录表或等价审计,记录 employeeId、externalUserId、操作、状态、错误码、重试次数和脱敏摘要;限制 HR/管理员查看和重试。","1) 普通员工不可查看他人同步状态;2) HR/管理员可查看和重试;3) 审计覆盖创建、更新、禁用、重试;4) 不保存 secret/token/完整 payload。","受影响流程:同步失败 -> 管理员查看 -> 重试 -> 审计;验证步骤:cd server && npm run prisma:validate;cd server && npm test -- tests/unit/routes/employees.wecomDirectory.test.js。","prisma|npm","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations/*|server/src/routes/employees.js|server/tests/unit/routes","[""HRWD-2""]","涉及 Prisma 迁移;回滚路径:回退代码并 drop 新增同步记录表和 flag seed。"
|
||||||
|
"HRWD-7","前端人事页面调整","在 People 页面展示企业微信状态和同步状态,补齐重试入口;离职确认说明会禁用 OA 账号并尝试禁用企业微信成员;移动端只做查看和轻量入口。","1) 员工详情/列表能看到未绑定、已绑定、同步成功、同步失败、配置缺失;2) HR/管理员可重试同步;3) 离职确认文案明确影响;4) PC/移动/企业微信 UA 无布局溢出。","受影响流程:管理员打开 People -> 查看员工企微状态 -> 触发重试 -> 发起离职确认;验证步骤:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/user-onboarding.spec.ts --workers=1 --reporter=list。","npm|playwright","DONE","DONE","DONE","client/src/pages/PeoplePage.tsx|client/src/api/employees.ts|client/tests/e2e/user-onboarding.spec.ts","[""HRWD-3"",""HRWD-4"",""HRWD-6""]","页面改动遵循 docs/产品设计规则.md;不新增移动批量管理。"
|
||||||
|
"HRWD-8","文档回写部署复检和提交","回写现有事实源和手册,完成本地后端/前端/Playwright 回归;本地通过后部署公网并复检员工管理、企业微信入口、移动/企微 UA 和权限边界。","1) docs/文档索引、事实源矩阵、人工试运行报告、PRD、企业微信规约和管理员手册已回写;2) Issue 状态按实测回填;3) 本地全测通过后才部署;4) 部署后公网复检通过或记录阻断;5) 中文 commit 引用 HRWD。","受影响流程:人事生命周期 -> 企业微信通讯录同步 -> 文档事实源 -> 部署复检;验证步骤:node scripts/qa/audit-docs.mjs --check-registry;git diff --check;set -a && source ~/.hyb-oa/live-smoke.env && set +a && LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;git commit -m 'HRWD 实现人事生命周期企微通讯录同步'。","node|bash|git|playwright","DONE","DONE","DONE","docs/海音达OA-MVP需求规格说明书.md|docs/需求-企业微信基础设施规约.md|docs/管理员手册.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|docs/trialrun/人工试运行报告.md","[""HRWD-7""]","公网已部署并复检通过;最终报告 artifacts/qa-flow/live-smoke-readiness-20260517-205813/summary.json;企微通讯录配置缺失时 live 重试为 SKIP wecom_contact_sync_disabled;最终 payment_batch_v1=force_on,erp_sync=force_off。"
|
||||||
|
"HRWD-9","部署 seed 保护正式 feature flag","修正 seed-config 功能开关种子逻辑,只创建缺失开关,不覆盖已有正式运行时配置,避免后续部署再次把 payment_batch_v1 等正式 flag 重置为 bootstrap 默认值。","1) 已存在 feature flag 不被 seed-config 覆盖;2) 新增缺失 feature flag 仍可 bootstrap 创建;3) 部署后 payment_batch_v1 保持 force_on,erp_sync 保持 force_off;4) 不新增业务 API/schema/UI。","受影响流程:部署 -> seed-config -> 正式 feature flag 状态 -> 付款批次常开;验证步骤:cd server && node --check src/scripts/seed-config.js;set -a && source ~/.hyb-oa/live-smoke.env && set +a && SYNC_ENV=0 bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/check-live-feature-flags.mjs --expect payment_batch_v1=force_on --expect erp_sync=force_off --expect employee_lifecycle_v1=enabled --expect hr_wecom_directory_sync_v1=disabled。","node|bash","DONE","DONE","DONE","server/src/scripts/seed-config.js","[""HRWD-8""]","完成:seed-config 改为 create-only 并忽略 P2002 冲突;第二次部署后正式 flag 检查保持 PASS,避免部署重置付款批次正式常开状态。"
|
||||||
|
@@ -55,6 +55,9 @@ MAX_UPLOAD_SIZE_BYTES="10485760"
|
|||||||
# WECOM_AGENT_SECRET=""
|
# WECOM_AGENT_SECRET=""
|
||||||
# WECOM_CHECKIN_SYNC_ENABLED="false"
|
# WECOM_CHECKIN_SYNC_ENABLED="false"
|
||||||
# WECOM_CHECKIN_SECRET=""
|
# WECOM_CHECKIN_SECRET=""
|
||||||
|
# WECOM_CONTACT_SYNC_ENABLED="false"
|
||||||
|
# WECOM_CONTACT_SECRET=""
|
||||||
|
# WECOM_CONTACT_DEFAULT_DEPARTMENT_ID=""
|
||||||
|
|
||||||
DELEGATION_REVOKE_CRON="0 * * * *"
|
DELEGATION_REVOKE_CRON="0 * * * *"
|
||||||
|
|
||||||
@@ -124,4 +127,5 @@ PRESENCE_BOARD_ENABLED=false
|
|||||||
ATTENDANCE_UNIFIED_ENABLED=false
|
ATTENDANCE_UNIFIED_ENABLED=false
|
||||||
WEEKLY_REPORT_ENABLED=false
|
WEEKLY_REPORT_ENABLED=false
|
||||||
EMPLOYEE_LIFECYCLE_ENABLED=false
|
EMPLOYEE_LIFECYCLE_ENABLED=false
|
||||||
|
WECOM_CONTACT_SYNC_ENABLED=false
|
||||||
TODO_BOARD_ENABLED=false
|
TODO_BOARD_ENABLED=false
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
CREATE TABLE "wecom_directory_sync_runs" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"employee_id" UUID NOT NULL,
|
||||||
|
"external_user_id" VARCHAR(128),
|
||||||
|
"operation" VARCHAR(32) NOT NULL,
|
||||||
|
"status" VARCHAR(24) NOT NULL DEFAULT 'running',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"error_code" VARCHAR(80),
|
||||||
|
"error_message" TEXT,
|
||||||
|
"response_summary" JSONB,
|
||||||
|
"triggered_by" UUID,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "wecom_directory_sync_runs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "idx_wecom_directory_sync_runs_employee_created"
|
||||||
|
ON "wecom_directory_sync_runs"("employee_id", "created_at");
|
||||||
|
|
||||||
|
CREATE INDEX "idx_wecom_directory_sync_runs_status_created"
|
||||||
|
ON "wecom_directory_sync_runs"("status", "created_at");
|
||||||
|
|
||||||
|
CREATE INDEX "idx_wecom_directory_sync_runs_operation_created"
|
||||||
|
ON "wecom_directory_sync_runs"("operation", "created_at");
|
||||||
|
|
||||||
|
CREATE INDEX "idx_wecom_directory_sync_runs_triggered_by"
|
||||||
|
ON "wecom_directory_sync_runs"("triggered_by");
|
||||||
|
|
||||||
|
ALTER TABLE "wecom_directory_sync_runs"
|
||||||
|
ADD CONSTRAINT "wecom_directory_sync_runs_employee_id_fkey"
|
||||||
|
FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "wecom_directory_sync_runs"
|
||||||
|
ADD CONSTRAINT "wecom_directory_sync_runs_triggered_by_fkey"
|
||||||
|
FOREIGN KEY ("triggered_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
INSERT INTO "feature_flags" (
|
||||||
|
"id",
|
||||||
|
"flag_key",
|
||||||
|
"override_mode",
|
||||||
|
"rollout_type",
|
||||||
|
"department_ids",
|
||||||
|
"note",
|
||||||
|
"version",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
) VALUES (
|
||||||
|
'19d97df5-40a0-4dd3-a0b4-91a9d7e8e305'::uuid,
|
||||||
|
'hr_wecom_directory_sync_v1',
|
||||||
|
'force_off',
|
||||||
|
'global',
|
||||||
|
'[]'::jsonb,
|
||||||
|
'bootstrap default: force_off',
|
||||||
|
1,
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
) ON CONFLICT ("flag_key") DO NOTHING;
|
||||||
@@ -620,6 +620,8 @@ model Employee {
|
|||||||
leave_ledgers LeaveLedger[]
|
leave_ledgers LeaveLedger[]
|
||||||
wecom_checkin_records WecomCheckinRecord[]
|
wecom_checkin_records WecomCheckinRecord[]
|
||||||
wecom_checkin_sync_runs WecomCheckinSyncRun[] @relation("WecomCheckinSyncRunTrigger")
|
wecom_checkin_sync_runs WecomCheckinSyncRun[] @relation("WecomCheckinSyncRunTrigger")
|
||||||
|
wecom_directory_sync_runs WecomDirectorySyncRun[] @relation("WecomDirectorySyncRunEmployee")
|
||||||
|
triggered_wecom_directory_sync_runs WecomDirectorySyncRun[] @relation("WecomDirectorySyncRunTrigger")
|
||||||
attendance_corrections AttendanceCorrection[]
|
attendance_corrections AttendanceCorrection[]
|
||||||
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
|
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
|
||||||
purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant")
|
purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant")
|
||||||
@@ -1991,6 +1993,31 @@ model WecomCheckinRecord {
|
|||||||
@@map("wecom_checkin_records")
|
@@map("wecom_checkin_records")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model WecomDirectorySyncRun {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
employee_id String @db.Uuid
|
||||||
|
external_user_id String? @db.VarChar(128)
|
||||||
|
operation String @db.VarChar(32)
|
||||||
|
status String @default("running") @db.VarChar(24)
|
||||||
|
attempts Int @default(1)
|
||||||
|
error_code String? @db.VarChar(80)
|
||||||
|
error_message String?
|
||||||
|
response_summary Json?
|
||||||
|
triggered_by String? @db.Uuid
|
||||||
|
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
employee Employee @relation("WecomDirectorySyncRunEmployee", fields: [employee_id], references: [id], onDelete: Cascade)
|
||||||
|
trigger Employee? @relation("WecomDirectorySyncRunTrigger", fields: [triggered_by], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([employee_id, created_at], map: "idx_wecom_directory_sync_runs_employee_created")
|
||||||
|
@@index([status, created_at], map: "idx_wecom_directory_sync_runs_status_created")
|
||||||
|
@@index([operation, created_at], map: "idx_wecom_directory_sync_runs_operation_created")
|
||||||
|
@@index([triggered_by], map: "idx_wecom_directory_sync_runs_triggered_by")
|
||||||
|
@@map("wecom_directory_sync_runs")
|
||||||
|
}
|
||||||
|
|
||||||
model AttendanceCorrection {
|
model AttendanceCorrection {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
owner_id String @db.Uuid
|
owner_id String @db.Uuid
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ function getConfig() {
|
|||||||
wecomAgentSecret: process.env.WECOM_AGENT_SECRET ?? "",
|
wecomAgentSecret: process.env.WECOM_AGENT_SECRET ?? "",
|
||||||
wecomCheckinSyncEnabled: process.env.WECOM_CHECKIN_SYNC_ENABLED === "true",
|
wecomCheckinSyncEnabled: process.env.WECOM_CHECKIN_SYNC_ENABLED === "true",
|
||||||
wecomCheckinSecret: process.env.WECOM_CHECKIN_SECRET ?? "",
|
wecomCheckinSecret: process.env.WECOM_CHECKIN_SECRET ?? "",
|
||||||
|
wecomContactSyncEnabled: process.env.WECOM_CONTACT_SYNC_ENABLED === "true",
|
||||||
|
wecomContactSecret: process.env.WECOM_CONTACT_SECRET ?? "",
|
||||||
|
wecomContactDefaultDepartmentId: process.env.WECOM_CONTACT_DEFAULT_DEPARTMENT_ID ?? "",
|
||||||
wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true",
|
wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true",
|
||||||
wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "",
|
wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "",
|
||||||
wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "",
|
wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "",
|
||||||
|
|||||||
+177
-4
@@ -12,6 +12,13 @@ const { AppError } = require("../utils/AppError");
|
|||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { buildAuditLogData } = require("../utils/audit");
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
|
const {
|
||||||
|
getLatestWecomDirectorySync,
|
||||||
|
mapWecomDirectoryState,
|
||||||
|
mapWecomDirectorySyncRun,
|
||||||
|
normalizeOperation,
|
||||||
|
syncWecomDirectoryForEmployee,
|
||||||
|
} = require("../services/integrations/wecomDirectory");
|
||||||
const { listOrganizationDepartments } = require("../services/organization");
|
const { listOrganizationDepartments } = require("../services/organization");
|
||||||
const { StorageNotFoundError, openReadStream, parseStoragePath, writeBuffer } = require("../services/storage");
|
const { StorageNotFoundError, openReadStream, parseStoragePath, writeBuffer } = require("../services/storage");
|
||||||
|
|
||||||
@@ -71,6 +78,10 @@ const OFFBOARDING_SCHEMA = z.object({
|
|||||||
notes: z.string().trim().max(500).nullable().optional(),
|
notes: z.string().trim().max(500).nullable().optional(),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
|
const WECOM_DIRECTORY_RETRY_SCHEMA = z.object({
|
||||||
|
operation: z.enum(["create", "update", "disable"]).optional(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
const CONTRACT_CREATE_SCHEMA = z.object({
|
const CONTRACT_CREATE_SCHEMA = z.object({
|
||||||
contractNo: z.string().trim().min(1).max(100),
|
contractNo: z.string().trim().min(1).max(100),
|
||||||
contractType: CONTRACT_TYPE_SCHEMA,
|
contractType: CONTRACT_TYPE_SCHEMA,
|
||||||
@@ -155,6 +166,50 @@ function canManageLifecycle(fastify, request) {
|
|||||||
return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "system.settings") || fastify.hasPermission(request.employee, "*");
|
return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "system.settings") || fastify.hasPermission(request.employee, "*");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canUseWecomDirectorySync(fastify) {
|
||||||
|
return Boolean(
|
||||||
|
fastify?.prisma?.wecomDirectorySyncRun
|
||||||
|
&& fastify?.prisma?.wecomIdentityBinding
|
||||||
|
&& fastify?.prisma?.featureFlag,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trySyncWecomDirectory(fastify, request, options) {
|
||||||
|
if (!canUseWecomDirectorySync(fastify)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operation = normalizeOperation(options.operation);
|
||||||
|
const result = await syncWecomDirectoryForEmployee({
|
||||||
|
prisma: fastify.prisma,
|
||||||
|
config: fastify.config,
|
||||||
|
employeeId: options.employeeId,
|
||||||
|
actorId: request.employee.id,
|
||||||
|
operation,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fastify.prisma.auditLog?.create) {
|
||||||
|
await fastify.prisma.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: options.auditAction ?? `WECOM_DIRECTORY_${operation.toUpperCase()}`,
|
||||||
|
targetType: "employee",
|
||||||
|
targetId: options.employeeId,
|
||||||
|
extra: {
|
||||||
|
operation,
|
||||||
|
status: result.status,
|
||||||
|
code: result.code,
|
||||||
|
skipped: Boolean(result.skipped),
|
||||||
|
syncRunId: result.run?.id ?? null,
|
||||||
|
externalUserId: result.run?.externalUserId ?? null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function canUploadAvatar(fastify, request, employeeId) {
|
function canUploadAvatar(fastify, request, employeeId) {
|
||||||
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users");
|
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users");
|
||||||
}
|
}
|
||||||
@@ -535,6 +590,13 @@ async function buildEmployeeProfile(prisma, employeeId) {
|
|||||||
roles: { select: { role_code: true } },
|
roles: { select: { role_code: true } },
|
||||||
department: { select: { id: true, name: true } },
|
department: { select: { id: true, name: true } },
|
||||||
direct_manager: { select: { id: true, employee_no: true, name: true, avatar_path: true } },
|
direct_manager: { select: { id: true, employee_no: true, name: true, avatar_path: true } },
|
||||||
|
wecom_identity_binding: {
|
||||||
|
select: { external_user_id: true, external_open_user_id: true },
|
||||||
|
},
|
||||||
|
wecom_directory_sync_runs: {
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.employeeChecklistTask.findMany({ where: { employee_id: employeeId }, orderBy: [{ created_at: "desc" }] }),
|
prisma.employeeChecklistTask.findMany({ where: { employee_id: employeeId }, orderBy: [{ created_at: "desc" }] }),
|
||||||
@@ -584,6 +646,7 @@ async function buildEmployeeProfile(prisma, employeeId) {
|
|||||||
avatarUrl: buildAvatarUrl(employee.direct_manager.id, employee.direct_manager.avatar_path),
|
avatarUrl: buildAvatarUrl(employee.direct_manager.id, employee.direct_manager.avatar_path),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
wecomDirectory: mapWecomDirectoryState(employee),
|
||||||
},
|
},
|
||||||
lifecycle: {
|
lifecycle: {
|
||||||
events: lifecycleEvents.map(mapLifecycleEvent),
|
events: lifecycleEvents.map(mapLifecycleEvent),
|
||||||
@@ -745,6 +808,90 @@ async function employeesRoutes(fastify) {
|
|||||||
return sendData(reply, request, stats);
|
return sendData(reply, request, stats);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.get("/employees/:id/wecom-directory-sync", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request);
|
||||||
|
if (!canManageLifecycle(fastify, request)) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||||
|
}
|
||||||
|
if (!canUseWecomDirectorySync(fastify)) {
|
||||||
|
throw buildValidationError({ featureFlag: "hr_wecom_directory_sync_v1_unavailable" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeeId = parseId(request.params?.id);
|
||||||
|
const employee = await fastify.prisma.employee.findUnique({
|
||||||
|
where: { id: employeeId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
wecom_identity_binding: {
|
||||||
|
select: { external_user_id: true, external_open_user_id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!employee) {
|
||||||
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const runs = await fastify.prisma.wecomDirectorySyncRun.findMany({
|
||||||
|
where: { employee_id: employeeId },
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
employeeId,
|
||||||
|
wecomDirectory: mapWecomDirectoryState({ binding: employee.wecom_identity_binding, runs }),
|
||||||
|
items: runs.map(mapWecomDirectorySyncRun),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/employees/:id/wecom-directory-sync/retry", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request);
|
||||||
|
if (!canManageLifecycle(fastify, request)) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||||
|
}
|
||||||
|
if (!canUseWecomDirectorySync(fastify)) {
|
||||||
|
throw buildValidationError({ featureFlag: "hr_wecom_directory_sync_v1_unavailable" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeeId = parseId(request.params?.id);
|
||||||
|
const body = parseBody(WECOM_DIRECTORY_RETRY_SCHEMA, request.body);
|
||||||
|
const employee = await fastify.prisma.employee.findUnique({
|
||||||
|
where: { id: employeeId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
employment_status: true,
|
||||||
|
wecom_identity_binding: {
|
||||||
|
select: { external_user_id: true, external_open_user_id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!employee) {
|
||||||
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const operation = body.operation
|
||||||
|
?? (employee.status === "archived" || employee.employment_status === "inactive"
|
||||||
|
? "disable"
|
||||||
|
: employee.wecom_identity_binding?.external_user_id
|
||||||
|
? "update"
|
||||||
|
: "create");
|
||||||
|
|
||||||
|
const result = await trySyncWecomDirectory(fastify, request, {
|
||||||
|
employeeId,
|
||||||
|
operation,
|
||||||
|
auditAction: "WECOM_DIRECTORY_RETRY",
|
||||||
|
});
|
||||||
|
const state = await getLatestWecomDirectorySync(fastify.prisma, employeeId);
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
employeeId,
|
||||||
|
operation,
|
||||||
|
result,
|
||||||
|
wecomDirectory: state,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
fastify.get("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.get("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
await ensureFeatureEnabled(fastify, request);
|
await ensureFeatureEnabled(fastify, request);
|
||||||
const employeeId = parseId(request.params?.id);
|
const employeeId = parseId(request.params?.id);
|
||||||
@@ -1176,8 +1323,18 @@ async function employeesRoutes(fastify) {
|
|||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const shouldSyncDirectory =
|
||||||
|
managerMode
|
||||||
|
&& (
|
||||||
|
Object.prototype.hasOwnProperty.call(body, "departmentId")
|
||||||
|
|| Object.prototype.hasOwnProperty.call(body, "directManagerId")
|
||||||
|
|| Object.prototype.hasOwnProperty.call(body, "title")
|
||||||
|
);
|
||||||
|
const directorySync = shouldSyncDirectory
|
||||||
|
? await trySyncWecomDirectory(fastify, request, { employeeId, operation: "update" })
|
||||||
|
: null;
|
||||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||||
return sendData(reply, request, profile);
|
return sendData(reply, request, { ...profile, wecomDirectorySync: directorySync });
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post("/employees/:id/onboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.post("/employees/:id/onboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
@@ -1211,8 +1368,12 @@ async function employeesRoutes(fastify) {
|
|||||||
const checklist = buildDefaultChecklist("onboarding", employeeId).map((item) => ({ ...item, due_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null }));
|
const checklist = buildDefaultChecklist("onboarding", employeeId).map((item) => ({ ...item, due_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null }));
|
||||||
await tx.employeeChecklistTask.createMany({ data: checklist, skipDuplicates: false });
|
await tx.employeeChecklistTask.createMany({ data: checklist, skipDuplicates: false });
|
||||||
});
|
});
|
||||||
|
const directorySync = await trySyncWecomDirectory(fastify, request, {
|
||||||
|
employeeId,
|
||||||
|
operation: "create",
|
||||||
|
});
|
||||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||||
return sendData(reply, request, profile);
|
return sendData(reply, request, { ...profile, wecomDirectorySync: directorySync });
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post("/employees/:id/regularize", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.post("/employees/:id/regularize", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
@@ -1258,8 +1419,12 @@ async function employeesRoutes(fastify) {
|
|||||||
notes: body.notes,
|
notes: body.notes,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const directorySync = await trySyncWecomDirectory(fastify, request, {
|
||||||
|
employeeId,
|
||||||
|
operation: "update",
|
||||||
|
});
|
||||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||||
return sendData(reply, request, profile);
|
return sendData(reply, request, { ...profile, wecomDirectorySync: directorySync });
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post("/employees/:id/offboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.post("/employees/:id/offboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
@@ -1341,8 +1506,16 @@ async function employeesRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const directorySync = result.completedOffboarding
|
||||||
|
? await trySyncWecomDirectory(fastify, request, { employeeId, operation: "disable" })
|
||||||
|
: null;
|
||||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||||
return sendData(reply, request, { ...profile.lifecycle, autoAdvanced: result.autoChanged });
|
return sendData(reply, request, {
|
||||||
|
...profile.lifecycle,
|
||||||
|
autoAdvanced: result.autoChanged,
|
||||||
|
wecomDirectory: profile.employee.wecomDirectory,
|
||||||
|
wecomDirectorySync: directorySync,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fastify.post("/employees/:id/checklist-tasks/:taskId/complete", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.post("/employees/:id/checklist-tasks/:taskId/complete", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
|||||||
@@ -268,6 +268,15 @@ const FEATURE_FLAG_BACKEND_IMPACT = Object.freeze({
|
|||||||
routes: ["/api/employees", "/api/performance/cycles", "/api/performance/reviews"],
|
routes: ["/api/employees", "/api/performance/cycles", "/api/performance/reviews"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
hr_wecom_directory_sync_v1: [
|
||||||
|
{
|
||||||
|
label: "企微通讯录同步",
|
||||||
|
routes: [
|
||||||
|
"/api/employees/:id/wecom-directory-sync",
|
||||||
|
"/api/employees/:id/wecom-directory-sync/retry",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
todo_board_v1: [
|
todo_board_v1: [
|
||||||
{
|
{
|
||||||
label: "待办工作台",
|
label: "待办工作台",
|
||||||
|
|||||||
+158
-2
@@ -12,6 +12,10 @@ const {
|
|||||||
} = require("../services/employeeDirectoryFilters");
|
} = require("../services/employeeDirectoryFilters");
|
||||||
const { applyEmployeeTransfer } = require("../services/employeeLifecycle");
|
const { applyEmployeeTransfer } = require("../services/employeeLifecycle");
|
||||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
|
const {
|
||||||
|
mapWecomDirectoryState,
|
||||||
|
syncWecomDirectoryForEmployee,
|
||||||
|
} = require("../services/integrations/wecomDirectory");
|
||||||
const { AppError } = require("../utils/AppError");
|
const { AppError } = require("../utils/AppError");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { buildAuditLogData } = require("../utils/audit");
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
@@ -53,6 +57,7 @@ const createBodySchema = z.object({
|
|||||||
hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
roles: z.array(roleCodeSchema).min(1).optional(),
|
roles: z.array(roleCodeSchema).min(1).optional(),
|
||||||
password: z.string().trim().min(1).optional(),
|
password: z.string().trim().min(1).optional(),
|
||||||
|
syncWecomDirectory: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateBodySchema = z.object({
|
const updateBodySchema = z.object({
|
||||||
@@ -218,6 +223,7 @@ function mapEmployeeSummary(row) {
|
|||||||
hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
|
hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||||
avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null,
|
avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null,
|
||||||
roleCodes: row.roles.map((item) => item.role_code),
|
roleCodes: row.roles.map((item) => item.role_code),
|
||||||
|
wecomDirectory: mapWecomDirectoryState(row),
|
||||||
createdAt: row.created_at?.toISOString?.() ?? null,
|
createdAt: row.created_at?.toISOString?.() ?? null,
|
||||||
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
||||||
};
|
};
|
||||||
@@ -445,6 +451,48 @@ async function setEmployeeAccountStatus(tx, request, options) {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canUseWecomDirectorySync(fastify) {
|
||||||
|
return Boolean(
|
||||||
|
fastify?.prisma?.wecomDirectorySyncRun
|
||||||
|
&& fastify?.prisma?.wecomIdentityBinding
|
||||||
|
&& fastify?.prisma?.featureFlag,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trySyncWecomDirectory(fastify, request, options) {
|
||||||
|
if (!canUseWecomDirectorySync(fastify)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await syncWecomDirectoryForEmployee({
|
||||||
|
prisma: fastify.prisma,
|
||||||
|
config: fastify.config,
|
||||||
|
employeeId: options.employeeId,
|
||||||
|
actorId: request.employee.id,
|
||||||
|
operation: options.operation,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fastify.prisma.auditLog?.create) {
|
||||||
|
await fastify.prisma.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: `WECOM_DIRECTORY_${String(options.operation ?? "update").toUpperCase()}`,
|
||||||
|
targetType: "employee",
|
||||||
|
targetId: options.employeeId,
|
||||||
|
extra: {
|
||||||
|
status: result.status,
|
||||||
|
code: result.code,
|
||||||
|
skipped: Boolean(result.skipped),
|
||||||
|
syncRunId: result.run?.id ?? null,
|
||||||
|
externalUserId: result.run?.externalUserId ?? null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function generateOneTimePassword() {
|
function generateOneTimePassword() {
|
||||||
const password = `ResetA1${crypto.randomBytes(6).toString("hex")}`;
|
const password = `ResetA1${crypto.randomBytes(6).toString("hex")}`;
|
||||||
const errors = validatePassword(password);
|
const errors = validatePassword(password);
|
||||||
@@ -978,6 +1026,13 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
department: { select: { id: true, name: true } },
|
department: { select: { id: true, name: true } },
|
||||||
direct_manager: { select: { id: true, employee_no: true, name: true } },
|
direct_manager: { select: { id: true, employee_no: true, name: true } },
|
||||||
roles: { select: { role_code: true } },
|
roles: { select: { role_code: true } },
|
||||||
|
wecom_identity_binding: {
|
||||||
|
select: { external_user_id: true, external_open_user_id: true },
|
||||||
|
},
|
||||||
|
wecom_directory_sync_runs: {
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
fastify.prisma.employee.count({ where }),
|
fastify.prisma.employee.count({ where }),
|
||||||
@@ -1033,6 +1088,13 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
created_at: true,
|
created_at: true,
|
||||||
updated_at: true,
|
updated_at: true,
|
||||||
roles: { select: { role_code: true } },
|
roles: { select: { role_code: true } },
|
||||||
|
wecom_identity_binding: {
|
||||||
|
select: { external_user_id: true, external_open_user_id: true },
|
||||||
|
},
|
||||||
|
wecom_directory_sync_runs: {
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1126,10 +1188,18 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
where: { employee_id: created.employee.id },
|
where: { employee_id: created.employee.id },
|
||||||
select: { role_code: true },
|
select: { role_code: true },
|
||||||
});
|
});
|
||||||
|
const directorySync =
|
||||||
|
body.syncWecomDirectory === false
|
||||||
|
? null
|
||||||
|
: await trySyncWecomDirectory(fastify, request, {
|
||||||
|
employeeId: created.employee.id,
|
||||||
|
operation: "create",
|
||||||
|
});
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
...mapEmployeeSummary({ ...created.employee, roles }),
|
...mapEmployeeSummary({ ...created.employee, roles }),
|
||||||
oneTimePassword: created.oneTimePassword,
|
oneTimePassword: created.oneTimePassword,
|
||||||
|
wecomDirectorySync: directorySync,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1173,6 +1243,10 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const employeeId of body.employeeIds) {
|
||||||
|
await trySyncWecomDirectory(fastify, request, { employeeId, operation: "update" });
|
||||||
|
}
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
||||||
employeeIds: body.employeeIds,
|
employeeIds: body.employeeIds,
|
||||||
@@ -1221,6 +1295,10 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const employeeId of body.employeeIds) {
|
||||||
|
await trySyncWecomDirectory(fastify, request, { employeeId, operation: "update" });
|
||||||
|
}
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
||||||
employeeIds: body.employeeIds,
|
employeeIds: body.employeeIds,
|
||||||
@@ -1268,6 +1346,10 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const employeeId of body.employeeIds) {
|
||||||
|
await trySyncWecomDirectory(fastify, request, { employeeId, operation: "disable" });
|
||||||
|
}
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
||||||
employeeIds: body.employeeIds,
|
employeeIds: body.employeeIds,
|
||||||
@@ -1312,6 +1394,10 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const employeeId of body.employeeIds) {
|
||||||
|
await trySyncWecomDirectory(fastify, request, { employeeId, operation: "update" });
|
||||||
|
}
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
||||||
employeeIds: body.employeeIds,
|
employeeIds: body.employeeIds,
|
||||||
@@ -1459,7 +1545,21 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sendData(reply, request, result);
|
const wecomDirectorySync = [];
|
||||||
|
for (const item of result.imported) {
|
||||||
|
const syncResult = await trySyncWecomDirectory(fastify, request, {
|
||||||
|
employeeId: item.employeeId,
|
||||||
|
operation: "create",
|
||||||
|
});
|
||||||
|
if (syncResult) {
|
||||||
|
wecomDirectorySync.push(syncResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
...result,
|
||||||
|
wecomDirectorySync,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1520,6 +1620,13 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
created_at: true,
|
created_at: true,
|
||||||
updated_at: true,
|
updated_at: true,
|
||||||
roles: { select: { role_code: true } },
|
roles: { select: { role_code: true } },
|
||||||
|
wecom_identity_binding: {
|
||||||
|
select: { external_user_id: true, external_open_user_id: true },
|
||||||
|
},
|
||||||
|
wecom_directory_sync_runs: {
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -1589,6 +1696,13 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
created_at: true,
|
created_at: true,
|
||||||
updated_at: true,
|
updated_at: true,
|
||||||
roles: { select: { role_code: true } },
|
roles: { select: { role_code: true } },
|
||||||
|
wecom_identity_binding: {
|
||||||
|
select: { external_user_id: true, external_open_user_id: true },
|
||||||
|
},
|
||||||
|
wecom_directory_sync_runs: {
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1618,10 +1732,23 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldSyncDirectory =
|
||||||
|
Object.prototype.hasOwnProperty.call(body, "name")
|
||||||
|
|| Object.prototype.hasOwnProperty.call(body, "phone")
|
||||||
|
|| Object.prototype.hasOwnProperty.call(body, "email")
|
||||||
|
|| Object.prototype.hasOwnProperty.call(body, "departmentId")
|
||||||
|
|| Object.prototype.hasOwnProperty.call(body, "title");
|
||||||
|
const directorySync = shouldSyncDirectory
|
||||||
|
? await trySyncWecomDirectory(fastify, request, { employeeId: userId, operation: "update" })
|
||||||
|
: null;
|
||||||
|
|
||||||
return sendData(
|
return sendData(
|
||||||
reply,
|
reply,
|
||||||
request,
|
request,
|
||||||
mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }),
|
{
|
||||||
|
...mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }),
|
||||||
|
wecomDirectorySync: directorySync,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1718,6 +1845,13 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
created_at: true,
|
created_at: true,
|
||||||
updated_at: true,
|
updated_at: true,
|
||||||
roles: { select: { role_code: true } },
|
roles: { select: { role_code: true } },
|
||||||
|
wecom_identity_binding: {
|
||||||
|
select: { external_user_id: true, external_open_user_id: true },
|
||||||
|
},
|
||||||
|
wecom_directory_sync_runs: {
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -1783,6 +1917,13 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
created_at: true,
|
created_at: true,
|
||||||
updated_at: true,
|
updated_at: true,
|
||||||
roles: { select: { role_code: true } },
|
roles: { select: { role_code: true } },
|
||||||
|
wecom_identity_binding: {
|
||||||
|
select: { external_user_id: true, external_open_user_id: true },
|
||||||
|
},
|
||||||
|
wecom_directory_sync_runs: {
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1870,10 +2011,15 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
const directorySync = await trySyncWecomDirectory(fastify, request, {
|
||||||
|
employeeId: userId,
|
||||||
|
operation: "disable",
|
||||||
|
});
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
id: employee.id,
|
id: employee.id,
|
||||||
status: employee.status,
|
status: employee.status,
|
||||||
|
wecomDirectorySync: directorySync,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1903,10 +2049,15 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const directorySync = await trySyncWecomDirectory(fastify, request, {
|
||||||
|
employeeId: userId,
|
||||||
|
operation: "disable",
|
||||||
|
});
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
id: employee.id,
|
id: employee.id,
|
||||||
status: employee.status,
|
status: employee.status,
|
||||||
|
wecomDirectorySync: directorySync,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1928,10 +2079,15 @@ async function usersRoutes(fastify, opts = {}) {
|
|||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const directorySync = await trySyncWecomDirectory(fastify, request, {
|
||||||
|
employeeId: userId,
|
||||||
|
operation: "update",
|
||||||
|
});
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
id: employee.id,
|
id: employee.id,
|
||||||
status: employee.status,
|
status: employee.status,
|
||||||
|
wecomDirectorySync: directorySync,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -317,6 +317,13 @@ const DEFAULT_FEATURE_FLAGS = [
|
|||||||
department_ids: [],
|
department_ids: [],
|
||||||
note: "bootstrap default: force_off",
|
note: "bootstrap default: force_off",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
flag_key: "hr_wecom_directory_sync_v1",
|
||||||
|
override_mode: "force_off",
|
||||||
|
rollout_type: "global",
|
||||||
|
department_ids: [],
|
||||||
|
note: "bootstrap default: force_off",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
flag_key: "todo_board_v1",
|
flag_key: "todo_board_v1",
|
||||||
override_mode: "force_off",
|
override_mode: "force_off",
|
||||||
@@ -588,17 +595,8 @@ async function seedFeatureFlags(prisma) {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
for (const item of DEFAULT_FEATURE_FLAGS) {
|
for (const item of DEFAULT_FEATURE_FLAGS) {
|
||||||
await prisma.featureFlag.upsert({
|
await prisma.featureFlag.create({
|
||||||
where: { flag_key: item.flag_key },
|
data: {
|
||||||
update: {
|
|
||||||
override_mode: item.override_mode,
|
|
||||||
rollout_type: item.rollout_type,
|
|
||||||
department_ids: item.department_ids,
|
|
||||||
note: item.note,
|
|
||||||
updated_at: now,
|
|
||||||
updated_by: null,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
flag_key: item.flag_key,
|
flag_key: item.flag_key,
|
||||||
override_mode: item.override_mode,
|
override_mode: item.override_mode,
|
||||||
rollout_type: item.rollout_type,
|
rollout_type: item.rollout_type,
|
||||||
@@ -609,6 +607,11 @@ async function seedFeatureFlags(prisma) {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
updated_by: null,
|
updated_by: null,
|
||||||
},
|
},
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error?.code === "P2002") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const FEATURE_FLAG_KEYS = {
|
|||||||
attendanceUnified: "attendance_unified_v1",
|
attendanceUnified: "attendance_unified_v1",
|
||||||
weeklyReport: "weekly_report_v1",
|
weeklyReport: "weekly_report_v1",
|
||||||
employeeLifecycle: "employee_lifecycle_v1",
|
employeeLifecycle: "employee_lifecycle_v1",
|
||||||
|
hrWecomDirectorySync: "hr_wecom_directory_sync_v1",
|
||||||
todoBoard: "todo_board_v1",
|
todoBoard: "todo_board_v1",
|
||||||
wecomEntry: "wecom_entry_v1",
|
wecomEntry: "wecom_entry_v1",
|
||||||
projectDomain: "project_domain_v1",
|
projectDomain: "project_domain_v1",
|
||||||
@@ -140,6 +141,10 @@ function resolveEnvGate(config, flagKey) {
|
|||||||
return Boolean(config.employeeLifecycleEnabled);
|
return Boolean(config.employeeLifecycleEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (flagKey === FEATURE_FLAG_KEYS.hrWecomDirectorySync) {
|
||||||
|
return Boolean(config.wecomContactSyncEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
if (flagKey === FEATURE_FLAG_KEYS.todoBoard) {
|
if (flagKey === FEATURE_FLAG_KEYS.todoBoard) {
|
||||||
return Boolean(config.todoBoardEnabled);
|
return Boolean(config.todoBoardEnabled);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ async function getWecomAppAccessToken({ config, timeoutMs = 5000, fetchImpl = fe
|
|||||||
const corpId = toSafeString(config?.wecomCorpId, 128);
|
const corpId = toSafeString(config?.wecomCorpId, 128);
|
||||||
const agentId = toSafeString(config?.wecomAgentId, 64);
|
const agentId = toSafeString(config?.wecomAgentId, 64);
|
||||||
const agentSecret = toSafeString(config?.wecomAgentSecret, 500);
|
const agentSecret = toSafeString(config?.wecomAgentSecret, 500);
|
||||||
|
const requireAgentId = config?.wecomRequireAgentId !== false;
|
||||||
const missing = [];
|
const missing = [];
|
||||||
if (!corpId) missing.push('WECOM_CORP_ID');
|
if (!corpId) missing.push('WECOM_CORP_ID');
|
||||||
if (!agentId) missing.push('WECOM_AGENT_ID');
|
if (requireAgentId && !agentId) missing.push('WECOM_AGENT_ID');
|
||||||
if (!agentSecret) missing.push('WECOM_AGENT_SECRET');
|
if (!agentSecret) missing.push('WECOM_AGENT_SECRET');
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
return buildMissingConfigResult(missing);
|
return buildMissingConfigResult(missing);
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../featureFlags");
|
||||||
|
const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken");
|
||||||
|
|
||||||
|
const OPERATION_LIST = Object.freeze(["create", "update", "disable"]);
|
||||||
|
|
||||||
|
function toSafeString(value, maxLength = 200) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return String(value).trim().slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskPhone(value) {
|
||||||
|
const raw = toSafeString(value, 30);
|
||||||
|
if (raw.length <= 7) return raw ? "***" : "";
|
||||||
|
return `${raw.slice(0, 3)}****${raw.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeErrorMessage(value) {
|
||||||
|
return toSafeString(value, 500).replace(/[A-Za-z0-9_-]{24,}/g, "***");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeExternalUserId(value) {
|
||||||
|
return toSafeString(value, 128).replace(/[^a-zA-Z0-9._@-]+/g, "_").slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveExternalUserId(employee) {
|
||||||
|
const fromBinding = sanitizeExternalUserId(employee?.wecom_identity_binding?.external_user_id);
|
||||||
|
if (fromBinding) return fromBinding;
|
||||||
|
|
||||||
|
const fromEmployeeNo = sanitizeExternalUserId(employee?.employee_no);
|
||||||
|
if (fromEmployeeNo) return fromEmployeeNo;
|
||||||
|
|
||||||
|
const compactId = toSafeString(employee?.id, 64).replace(/-/g, "");
|
||||||
|
return compactId ? `oa_${compactId}`.slice(0, 64) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOperation(value) {
|
||||||
|
const operation = toSafeString(value, 32);
|
||||||
|
return OPERATION_LIST.includes(operation) ? operation : "update";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWecomDirectorySyncRun(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
employeeId: row.employee_id,
|
||||||
|
externalUserId: row.external_user_id ?? null,
|
||||||
|
operation: row.operation,
|
||||||
|
status: row.status,
|
||||||
|
attempts: Number(row.attempts ?? 1),
|
||||||
|
errorCode: row.error_code ?? null,
|
||||||
|
errorMessage: row.error_message ?? null,
|
||||||
|
responseSummary: row.response_summary ?? null,
|
||||||
|
triggeredBy: row.triggered_by ?? null,
|
||||||
|
createdAt: row.created_at?.toISOString?.() ?? null,
|
||||||
|
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWecomDirectoryState(input = {}) {
|
||||||
|
const binding = input.binding ?? input.wecom_identity_binding ?? null;
|
||||||
|
const runs = input.runs ?? input.wecom_directory_sync_runs ?? [];
|
||||||
|
const latestRun = Array.isArray(runs) && runs.length ? runs[0] : null;
|
||||||
|
const mappedRun = mapWecomDirectorySyncRun(latestRun);
|
||||||
|
const externalUserId = binding?.external_user_id ?? mappedRun?.externalUserId ?? null;
|
||||||
|
|
||||||
|
let status = "unbound";
|
||||||
|
if (binding?.external_user_id) status = "bound";
|
||||||
|
if (mappedRun?.status === "success") status = binding?.external_user_id ? "synced" : "sync_success";
|
||||||
|
if (mappedRun?.status === "skipped") status = "sync_skipped";
|
||||||
|
if (mappedRun?.status === "failed") status = "sync_failed";
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
externalUserId,
|
||||||
|
bound: Boolean(binding?.external_user_id),
|
||||||
|
latestSync: mappedRun,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeDirectoryResponse(payload) {
|
||||||
|
const sanitized = sanitizeWecomResponse(payload) ?? {};
|
||||||
|
return Object.keys(sanitized).length ? sanitized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDepartmentId(config) {
|
||||||
|
const raw = toSafeString(config?.wecomContactDefaultDepartmentId, 32);
|
||||||
|
const numeric = Number(raw);
|
||||||
|
return raw && Number.isInteger(numeric) && numeric > 0 ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDirectoryPayload({ employee, externalUserId, operation, departmentId }) {
|
||||||
|
const base = {
|
||||||
|
userid: externalUserId,
|
||||||
|
name: toSafeString(employee.name, 64) || externalUserId,
|
||||||
|
mobile: toSafeString(employee.phone, 30) || undefined,
|
||||||
|
email: toSafeString(employee.email, 128) || undefined,
|
||||||
|
position: toSafeString(employee.title, 64) || undefined,
|
||||||
|
department: [departmentId],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (operation === "disable") {
|
||||||
|
return {
|
||||||
|
userid: externalUserId,
|
||||||
|
enable: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === "update") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
enable: employee.status === "archived" ? 0 : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
enable: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDirectoryEndpoint(operation) {
|
||||||
|
return operation === "create" ? "/cgi-bin/user/create" : "/cgi-bin/user/update";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEmployeeForDirectory(prisma, employeeId) {
|
||||||
|
return prisma.employee.findUnique({
|
||||||
|
where: { id: employeeId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
employee_no: true,
|
||||||
|
name: true,
|
||||||
|
phone: true,
|
||||||
|
email: true,
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
wecom_identity_binding: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
external_user_id: true,
|
||||||
|
external_open_user_id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSyncRun(prisma, input) {
|
||||||
|
return prisma.wecomDirectorySyncRun.create({
|
||||||
|
data: {
|
||||||
|
employee_id: input.employeeId,
|
||||||
|
external_user_id: input.externalUserId || null,
|
||||||
|
operation: input.operation,
|
||||||
|
status: "running",
|
||||||
|
attempts: input.attempts ?? 1,
|
||||||
|
triggered_by: input.actorId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishSyncRun(prisma, runId, data) {
|
||||||
|
return prisma.wecomDirectorySyncRun.update({
|
||||||
|
where: { id: runId },
|
||||||
|
data: {
|
||||||
|
status: data.status,
|
||||||
|
error_code: data.errorCode ?? null,
|
||||||
|
error_message: data.errorMessage ? sanitizeErrorMessage(data.errorMessage) : null,
|
||||||
|
response_summary: data.responseSummary ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncWecomDirectoryForEmployee({
|
||||||
|
prisma,
|
||||||
|
config,
|
||||||
|
employeeId,
|
||||||
|
actorId = null,
|
||||||
|
operation,
|
||||||
|
fetchImpl = fetch,
|
||||||
|
}) {
|
||||||
|
const normalizedOperation = normalizeOperation(operation);
|
||||||
|
const employee = await fetchEmployeeForDirectory(prisma, employeeId);
|
||||||
|
if (!employee) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
status: "failed",
|
||||||
|
code: "employee_not_found",
|
||||||
|
message: "Employee not found",
|
||||||
|
run: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalUserId = deriveExternalUserId(employee);
|
||||||
|
const run = await createSyncRun(prisma, {
|
||||||
|
employeeId,
|
||||||
|
externalUserId,
|
||||||
|
operation: normalizedOperation,
|
||||||
|
actorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gate = await evaluateFeatureFlag({
|
||||||
|
prisma,
|
||||||
|
config,
|
||||||
|
flagKey: FEATURE_FLAG_KEYS.hrWecomDirectorySync,
|
||||||
|
departmentId: null,
|
||||||
|
strictDepartment: false,
|
||||||
|
});
|
||||||
|
if (!gate.enabled) {
|
||||||
|
const updatedRun = await finishSyncRun(prisma, run.id, {
|
||||||
|
status: "skipped",
|
||||||
|
errorCode: "wecom_contact_sync_disabled",
|
||||||
|
errorMessage: gate.reason,
|
||||||
|
responseSummary: { reason: gate.reason, envEnabled: gate.envEnabled },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
skipped: true,
|
||||||
|
status: "skipped",
|
||||||
|
code: "wecom_contact_sync_disabled",
|
||||||
|
message: gate.reason,
|
||||||
|
run: mapWecomDirectorySyncRun(updatedRun),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const departmentId = resolveDepartmentId(config);
|
||||||
|
const missing = [];
|
||||||
|
if (!toSafeString(config?.wecomCorpId, 128)) missing.push("WECOM_CORP_ID");
|
||||||
|
if (!toSafeString(config?.wecomContactSecret, 500)) missing.push("WECOM_CONTACT_SECRET");
|
||||||
|
if (!departmentId) missing.push("WECOM_CONTACT_DEFAULT_DEPARTMENT_ID");
|
||||||
|
if (!externalUserId) missing.push("external_user_id");
|
||||||
|
if (missing.length) {
|
||||||
|
const updatedRun = await finishSyncRun(prisma, run.id, {
|
||||||
|
status: "skipped",
|
||||||
|
errorCode: "wecom_contact_config_missing",
|
||||||
|
errorMessage: `Missing WeCom contact config: ${missing.join(",")}`,
|
||||||
|
responseSummary: { missing },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
skipped: true,
|
||||||
|
status: "skipped",
|
||||||
|
code: "wecom_contact_config_missing",
|
||||||
|
message: "WeCom contact config missing",
|
||||||
|
run: mapWecomDirectorySyncRun(updatedRun),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResult = await getWecomAppAccessToken({
|
||||||
|
config: {
|
||||||
|
...config,
|
||||||
|
wecomAgentId: config.wecomAgentId || "contact",
|
||||||
|
wecomAgentSecret: config.wecomContactSecret,
|
||||||
|
wecomRequireAgentId: false,
|
||||||
|
},
|
||||||
|
timeoutMs: config.wecomRequestTimeoutMs ?? 5000,
|
||||||
|
fetchImpl,
|
||||||
|
});
|
||||||
|
if (!tokenResult.ok) {
|
||||||
|
const updatedRun = await finishSyncRun(prisma, run.id, {
|
||||||
|
status: "failed",
|
||||||
|
errorCode: tokenResult.code,
|
||||||
|
errorMessage: tokenResult.message,
|
||||||
|
responseSummary: tokenResult.response,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
status: "failed",
|
||||||
|
code: tokenResult.code,
|
||||||
|
message: tokenResult.message,
|
||||||
|
run: mapWecomDirectorySyncRun(updatedRun),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = resolveDirectoryEndpoint(normalizedOperation);
|
||||||
|
const url = new URL(`https://qyapi.weixin.qq.com${endpoint}`);
|
||||||
|
url.searchParams.set("access_token", tokenResult.accessToken);
|
||||||
|
const payload = buildDirectoryPayload({
|
||||||
|
employee,
|
||||||
|
externalUserId,
|
||||||
|
operation: normalizedOperation,
|
||||||
|
departmentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const responseBody = await response.json().catch(() => null);
|
||||||
|
const summary = sanitizeDirectoryResponse(responseBody);
|
||||||
|
const errcode = Number(responseBody?.errcode ?? 0);
|
||||||
|
|
||||||
|
if (!response.ok || errcode !== 0) {
|
||||||
|
const code = !response.ok
|
||||||
|
? response.status >= 500
|
||||||
|
? "wecom_contact_http_5xx"
|
||||||
|
: "wecom_contact_http_error"
|
||||||
|
: "wecom_contact_api_error";
|
||||||
|
const updatedRun = await finishSyncRun(prisma, run.id, {
|
||||||
|
status: "failed",
|
||||||
|
errorCode: code,
|
||||||
|
errorMessage: !response.ok
|
||||||
|
? `WeCom contact HTTP ${response.status}`
|
||||||
|
: `WeCom contact errcode ${responseBody?.errcode ?? "missing_payload"}`,
|
||||||
|
responseSummary: !response.ok ? { status: response.status, body: summary } : summary,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
status: "failed",
|
||||||
|
code,
|
||||||
|
message: "WeCom contact sync failed",
|
||||||
|
run: mapWecomDirectorySyncRun(updatedRun),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedOperation !== "disable") {
|
||||||
|
await prisma.wecomIdentityBinding.upsert({
|
||||||
|
where: { employee_id: employeeId },
|
||||||
|
update: {
|
||||||
|
external_user_id: externalUserId,
|
||||||
|
updated_by: actorId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
employee_id: employeeId,
|
||||||
|
external_user_id: externalUserId,
|
||||||
|
created_by: actorId,
|
||||||
|
updated_by: actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRun = await finishSyncRun(prisma, run.id, {
|
||||||
|
status: "success",
|
||||||
|
responseSummary: {
|
||||||
|
...summary,
|
||||||
|
operation: normalizedOperation,
|
||||||
|
externalUserId,
|
||||||
|
phoneMasked: maskPhone(employee.phone),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
skipped: false,
|
||||||
|
status: "success",
|
||||||
|
code: "wecom_contact_synced",
|
||||||
|
message: null,
|
||||||
|
run: mapWecomDirectorySyncRun(updatedRun),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const updatedRun = await finishSyncRun(prisma, run.id, {
|
||||||
|
status: "failed",
|
||||||
|
errorCode: error?.name === "AbortError" ? "wecom_contact_timeout" : "wecom_contact_network_error",
|
||||||
|
errorMessage: error?.message ?? "WeCom contact network error",
|
||||||
|
responseSummary: null,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
status: "failed",
|
||||||
|
code: updatedRun.error_code,
|
||||||
|
message: updatedRun.error_message,
|
||||||
|
run: mapWecomDirectorySyncRun(updatedRun),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLatestWecomDirectorySync(prisma, employeeId) {
|
||||||
|
const [binding, latestRuns] = await Promise.all([
|
||||||
|
prisma.wecomIdentityBinding.findUnique({
|
||||||
|
where: { employee_id: employeeId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
external_user_id: true,
|
||||||
|
external_open_user_id: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.wecomDirectorySyncRun.findMany({
|
||||||
|
where: { employee_id: employeeId },
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: 1,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return mapWecomDirectoryState({ binding, runs: latestRuns });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
deriveExternalUserId,
|
||||||
|
getLatestWecomDirectorySync,
|
||||||
|
mapWecomDirectoryState,
|
||||||
|
mapWecomDirectorySyncRun,
|
||||||
|
normalizeOperation,
|
||||||
|
syncWecomDirectoryForEmployee,
|
||||||
|
};
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
|
||||||
|
const employeesRoutes = require("../../../src/routes/employees");
|
||||||
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
|
|
||||||
|
const ACTOR_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
|
const TASK_ID = "33333333-3333-4333-8333-333333333333";
|
||||||
|
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
function buildEmployeeRow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: EMPLOYEE_ID,
|
||||||
|
employee_no: "HYD000888",
|
||||||
|
name: "测试员工",
|
||||||
|
phone: "13800000000",
|
||||||
|
email: "[email protected]",
|
||||||
|
status: "active",
|
||||||
|
employment_status: "active",
|
||||||
|
department_id: DEPARTMENT_ID,
|
||||||
|
title: "工程师",
|
||||||
|
direct_manager_id: null,
|
||||||
|
hire_date: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
probation_end_date: null,
|
||||||
|
last_working_date: null,
|
||||||
|
work_location: null,
|
||||||
|
emergency_contact_name: null,
|
||||||
|
emergency_contact_phone: null,
|
||||||
|
notes: null,
|
||||||
|
avatar_path: null,
|
||||||
|
archived_at: null,
|
||||||
|
roles: [{ role_code: "employee" }],
|
||||||
|
department: { id: DEPARTMENT_ID, name: "研发部" },
|
||||||
|
direct_manager: null,
|
||||||
|
wecom_identity_binding: null,
|
||||||
|
wecom_directory_sync_runs: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFlagRecord(flagKey) {
|
||||||
|
return {
|
||||||
|
id: `${flagKey}-id`,
|
||||||
|
flag_key: flagKey,
|
||||||
|
override_mode: "force_on",
|
||||||
|
rollout_type: "global",
|
||||||
|
department_ids: [],
|
||||||
|
note: "test",
|
||||||
|
version: 1,
|
||||||
|
created_at: new Date("2026-05-17T00:00:00.000Z"),
|
||||||
|
created_by: null,
|
||||||
|
updated_at: new Date("2026-05-17T00:00:00.000Z"),
|
||||||
|
updated_by: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWecomFetch() {
|
||||||
|
return vi.fn(async (url) => {
|
||||||
|
const target = String(url);
|
||||||
|
if (target.includes("/cgi-bin/gettoken")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ errcode: 0, errmsg: "ok", access_token: "mock-access-token", expires_in: 7200 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (target.includes("/cgi-bin/user/create") || target.includes("/cgi-bin/user/update")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ errcode: 0, errmsg: "ok" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({ errcode: 404, errmsg: "not found" }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildApp({
|
||||||
|
actorPermissions = ["system.users"],
|
||||||
|
config = {},
|
||||||
|
employeeOverrides = {},
|
||||||
|
initialTaskStatus = "pending",
|
||||||
|
} = {}) {
|
||||||
|
const app = Fastify();
|
||||||
|
let employee = buildEmployeeRow(employeeOverrides);
|
||||||
|
let latestRun = null;
|
||||||
|
let taskStatus = initialTaskStatus;
|
||||||
|
|
||||||
|
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
|
||||||
|
const bindingUpsert = vi.fn(async ({ create, update }) => {
|
||||||
|
employee = {
|
||||||
|
...employee,
|
||||||
|
wecom_identity_binding: {
|
||||||
|
id: "binding-1",
|
||||||
|
external_user_id: update?.external_user_id ?? create.external_user_id,
|
||||||
|
external_open_user_id: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return employee.wecom_identity_binding;
|
||||||
|
});
|
||||||
|
const syncRunCreate = vi.fn(async ({ data }) => {
|
||||||
|
latestRun = {
|
||||||
|
id: "sync-run-1",
|
||||||
|
employee_id: data.employee_id,
|
||||||
|
external_user_id: data.external_user_id ?? null,
|
||||||
|
operation: data.operation,
|
||||||
|
status: data.status,
|
||||||
|
attempts: data.attempts ?? 1,
|
||||||
|
error_code: null,
|
||||||
|
error_message: null,
|
||||||
|
response_summary: null,
|
||||||
|
triggered_by: data.triggered_by ?? null,
|
||||||
|
created_at: new Date("2026-05-17T12:00:00.000Z"),
|
||||||
|
updated_at: new Date("2026-05-17T12:00:00.000Z"),
|
||||||
|
};
|
||||||
|
return latestRun;
|
||||||
|
});
|
||||||
|
const syncRunUpdate = vi.fn(async ({ data }) => {
|
||||||
|
latestRun = {
|
||||||
|
...latestRun,
|
||||||
|
status: data.status,
|
||||||
|
error_code: data.error_code ?? null,
|
||||||
|
error_message: data.error_message ?? null,
|
||||||
|
response_summary: data.response_summary ?? null,
|
||||||
|
updated_at: new Date("2026-05-17T12:01:00.000Z"),
|
||||||
|
};
|
||||||
|
employee = {
|
||||||
|
...employee,
|
||||||
|
wecom_directory_sync_runs: latestRun ? [latestRun] : [],
|
||||||
|
};
|
||||||
|
return latestRun;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate("config", {
|
||||||
|
employeeLifecycleEnabled: true,
|
||||||
|
wecomContactSyncEnabled: true,
|
||||||
|
wecomCorpId: "wx-test-corp",
|
||||||
|
wecomContactSecret: "contact-secret",
|
||||||
|
wecomContactDefaultDepartmentId: "1",
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
app.decorate("authenticate", async (request) => {
|
||||||
|
request.employee = {
|
||||||
|
id: ACTOR_ID,
|
||||||
|
department_id: DEPARTMENT_ID,
|
||||||
|
permissionCodes: actorPermissions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||||
|
request.employee = {
|
||||||
|
id: ACTOR_ID,
|
||||||
|
department_id: DEPARTMENT_ID,
|
||||||
|
permissionCodes: actorPermissions,
|
||||||
|
};
|
||||||
|
if (!actorPermissions.includes("*") && !actorPermissions.includes(permissionCode)) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.decorate("hasPermission", (_employee, permissionCode) => actorPermissions.includes("*") || actorPermissions.includes(permissionCode));
|
||||||
|
app.decorate("prisma", {
|
||||||
|
featureFlag: {
|
||||||
|
findUnique: vi.fn(async ({ where }) => buildFlagRecord(where.flag_key)),
|
||||||
|
},
|
||||||
|
employee: {
|
||||||
|
findUnique: vi.fn(async ({ where }) => (where.id === EMPLOYEE_ID ? employee : null)),
|
||||||
|
update: vi.fn(async ({ data }) => {
|
||||||
|
employee = {
|
||||||
|
...employee,
|
||||||
|
...(data.status !== undefined ? { status: data.status } : {}),
|
||||||
|
...(data.employment_status !== undefined ? { employment_status: data.employment_status } : {}),
|
||||||
|
...(data.archived_at !== undefined ? { archived_at: data.archived_at } : {}),
|
||||||
|
...(data.token_version !== undefined ? { token_version: 1 } : {}),
|
||||||
|
};
|
||||||
|
return employee;
|
||||||
|
}),
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
employeeChecklistTask: {
|
||||||
|
findFirst: vi.fn(async ({ where }) => (where.id === TASK_ID ? { id: TASK_ID, employee_id: EMPLOYEE_ID, status: taskStatus } : null)),
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
count: vi.fn(async ({ where }) => {
|
||||||
|
if (where.employee_id === EMPLOYEE_ID && where.scene === "offboarding" && where.status === "pending") {
|
||||||
|
return taskStatus === "pending" ? 0 : 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}),
|
||||||
|
update: vi.fn(async ({ data }) => {
|
||||||
|
taskStatus = data.status;
|
||||||
|
return { id: TASK_ID, employee_id: EMPLOYEE_ID, status: taskStatus };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
employeeLifecycleEvent: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
assetCard: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
workflowTask: {
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
wecomIdentityBinding: {
|
||||||
|
findUnique: vi.fn(async () => employee.wecom_identity_binding),
|
||||||
|
upsert: bindingUpsert,
|
||||||
|
},
|
||||||
|
wecomDirectorySyncRun: {
|
||||||
|
create: syncRunCreate,
|
||||||
|
update: syncRunUpdate,
|
||||||
|
findMany: vi.fn(async () => (latestRun ? [latestRun] : [])),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: auditCreate,
|
||||||
|
},
|
||||||
|
$transaction: vi.fn(async (callback) => callback(app.prisma)),
|
||||||
|
});
|
||||||
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return reply.status(error.statusCode).send({
|
||||||
|
requestId: request.id,
|
||||||
|
error: { code: error.code, message: error.message, details: error.details ?? {} },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.status(500).send({
|
||||||
|
requestId: request.id,
|
||||||
|
error: { code: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(employeesRoutes, { prefix: "/api" });
|
||||||
|
return { app, auditCreate, bindingUpsert, syncRunCreate, syncRunUpdate };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("employees WeCom directory sync", () => {
|
||||||
|
it("allows HR admins to retry WeCom member creation and keeps a sanitized sync run", async () => {
|
||||||
|
global.fetch = buildWecomFetch();
|
||||||
|
const { app, bindingUpsert, syncRunUpdate } = await buildApp();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/employees/${EMPLOYEE_ID}/wecom-directory-sync/retry`,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(bindingUpsert).toHaveBeenCalledWith(expect.objectContaining({ where: { employee_id: EMPLOYEE_ID } }));
|
||||||
|
expect(syncRunUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: "success",
|
||||||
|
response_summary: expect.objectContaining({ phoneMasked: "138****0000" }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(global.fetch.mock.calls.some((call) => String(call[0]).includes("/cgi-bin/user/create"))).toBe(true);
|
||||||
|
expect(response.json().data.result.run.responseSummary.phoneMasked).toBe("138****0000");
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records SKIP when contact secret is missing and does not call WeCom", async () => {
|
||||||
|
global.fetch = buildWecomFetch();
|
||||||
|
const { app } = await buildApp({ config: { wecomContactSecret: "" } });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/employees/${EMPLOYEE_ID}/wecom-directory-sync/retry`,
|
||||||
|
payload: { operation: "create" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data.result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
skipped: true,
|
||||||
|
status: "skipped",
|
||||||
|
code: "wecom_contact_config_missing",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-HR users from reading WeCom directory sync state", async () => {
|
||||||
|
const { app } = await buildApp({ actorPermissions: ["expense.view_own"] });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/employees/${EMPLOYEE_ID}/wecom-directory-sync`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the WeCom member when offboarding checklist completion archives the OA account", async () => {
|
||||||
|
global.fetch = buildWecomFetch();
|
||||||
|
const { app } = await buildApp({
|
||||||
|
employeeOverrides: {
|
||||||
|
employment_status: "offboarding",
|
||||||
|
wecom_identity_binding: { external_user_id: "HYD000888", external_open_user_id: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/employees/${EMPLOYEE_ID}/checklist-tasks/${TASK_ID}/complete`,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(global.fetch.mock.calls.some((call) => String(call[0]).includes("/cgi-bin/user/update"))).toBe(true);
|
||||||
|
const updateCall = global.fetch.mock.calls.find((call) => String(call[0]).includes("/cgi-bin/user/update"));
|
||||||
|
expect(JSON.parse(updateCall[1].body)).toEqual(expect.objectContaining({ userid: "HYD000888", enable: 0 }));
|
||||||
|
expect(response.json().data.wecomDirectorySync).toEqual(expect.objectContaining({ status: "success" }));
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户