From 9ae42dadc1667fd896c0507728f12cafaf5b1a21 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 17 May 2026 21:01:28 +0800 Subject: [PATCH] =?UTF-8?q?HRWD-0..9=20=E5=AE=9E=E7=8E=B0=E4=BA=BA?= =?UTF-8?q?=E4=BA=8B=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E4=BC=81=E5=BE=AE?= =?UTF-8?q?=E9=80=9A=E8=AE=AF=E5=BD=95=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/employees.ts | 54 +++ client/src/pages/EmployeeProfilePage.tsx | 78 +++- client/src/pages/PeoplePage.tsx | 229 +++++++--- deploy/trialrun/.env.example | 3 + docs/trialrun/人工试运行报告.md | 2 + docs/文档事实源状态矩阵.md | 11 +- docs/文档索引.md | 6 +- docs/海音达OA-MVP需求规格说明书.md | 13 +- docs/管理员手册.md | 39 +- docs/集成边界.md | 4 +- docs/需求-企业微信基础设施规约.md | 25 +- ...6-05-17_20-00-40-hr-wecom-directory-v1.csv | 11 + server/.env.example | 4 + .../migration.sql | 57 +++ server/prisma/schema.prisma | 27 ++ server/src/config.js | 3 + server/src/routes/employees.js | 181 +++++++- server/src/routes/featureFlags.js | 9 + server/src/routes/users.js | 160 ++++++- server/src/scripts/seed-config.js | 25 +- server/src/services/featureFlags/index.js | 5 + .../services/integrations/wecomAccessToken.js | 3 +- .../services/integrations/wecomDirectory.js | 399 ++++++++++++++++++ .../routes/employees.wecomDirectory.test.js | 328 ++++++++++++++ 24 files changed, 1553 insertions(+), 123 deletions(-) create mode 100644 issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv create mode 100644 server/prisma/migrations/20260517200040_hr_wecom_directory_v1/migration.sql create mode 100644 server/src/services/integrations/wecomDirectory.js create mode 100644 server/tests/unit/routes/employees.wecomDirectory.test.js diff --git a/client/src/api/employees.ts b/client/src/api/employees.ts index a921be7d..319fdf8b 100644 --- a/client/src/api/employees.ts +++ b/client/src/api/employees.ts @@ -7,6 +7,31 @@ export type EmployeeContractStatus = "active" | "expired" | "terminated"; export type EmployeeTrainingType = "internal" | "external" | "online"; export type EmployeeTrainingStatus = "planned" | "in_progress" | "completed" | "cancelled"; 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 | 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 = { id: string; @@ -31,6 +56,7 @@ export type EmployeeSummary = { roleCodes: string[]; createdAt: string | null; updatedAt: string | null; + wecomDirectory?: WecomDirectoryState; }; export type EmployeeListResponse = { @@ -175,6 +201,7 @@ export type EmployeeProfile = { roleCodes: string[]; department: EmployeeDepartmentSummary | null; directManager: { id: string; employeeNo: string; name: string; avatarUrl: string | null } | null; + wecomDirectory?: WecomDirectoryState; }; lifecycle: { events: EmployeeLifecycleEvent[]; @@ -403,6 +430,33 @@ export async function fetchEmployeeLifecycle(employeeId: string) { return apiRequestData(`/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() { return apiRequestData<{ items: OrganizationDepartmentSection[] }>("/api/employees/organization"); } diff --git a/client/src/pages/EmployeeProfilePage.tsx b/client/src/pages/EmployeeProfilePage.tsx index 2cecd335..8b702de2 100644 --- a/client/src/pages/EmployeeProfilePage.tsx +++ b/client/src/pages/EmployeeProfilePage.tsx @@ -18,6 +18,7 @@ import { patchEmployeeProfile, patchEmployeeTraining, regularizeEmployee, + retryEmployeeWecomDirectorySync, skipEmployeeChecklistTask, startEmployeeOffboarding, transferEmployee, @@ -29,6 +30,7 @@ import { type EmployeeTraining, type EmployeeTrainingStatus, type EmployeeTrainingType, + type WecomDirectoryState, } from "../api/employees"; import { fetchEmployeePerformanceTimeline } from "../api/performance"; import { useAuth } from "../auth/AuthContext"; @@ -151,6 +153,24 @@ function trainingTypeLabel(type: EmployeeTrainingType) { 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() { const { employeeId = "" } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); @@ -181,6 +201,7 @@ export function EmployeeProfilePage() { const [certificateForm, setCertificateForm] = useState(buildEmptyCertificateForm()); const [deleteAvatarConfirmOpen, setDeleteAvatarConfirmOpen] = useState(false); const [deleteContractTarget, setDeleteContractTarget] = useState(null); + const [offboardingConfirmOpen, setOffboardingConfirmOpen] = useState(false); const [form, setForm] = useState({ title: "", hireDate: "", @@ -362,12 +383,34 @@ export function EmployeeProfilePage() { const offboardingMutation = useMutation({ mutationFn: () => startEmployeeOffboarding(employeeId), onSuccess: async () => { + setOffboardingConfirmOpen(false); await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }); toast.success("已发起离职"); }, 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({ mutationFn: async () => { const payload = { @@ -575,6 +618,7 @@ export function EmployeeProfilePage() { const contracts = contractsQuery.data?.items ?? []; const trainings = trainingsQuery.data?.items ?? []; const certificates = certificatesQuery.data?.items ?? []; + const wecomDirectoryView = wecomDirectoryStatusView(profile.employee.wecomDirectory); function resetContractEditor() { setEditingContractId(null); @@ -673,7 +717,7 @@ export function EmployeeProfilePage() { - @@ -760,6 +804,28 @@ export function EmployeeProfilePage() { +
+
+
+
企业微信通讯录
+
+ + {wecomDirectoryView.hint} +
+
+ {canManageEmployeeProfile ? ( + + ) : null} +
+
+ {canEditExtendedFields ? (
@@ -1459,6 +1525,16 @@ export function EmployeeProfilePage() { } }} /> + { + offboardingMutation.mutate(); + }} + /> ); } diff --git a/client/src/pages/PeoplePage.tsx b/client/src/pages/PeoplePage.tsx index fe798106..15154005 100644 --- a/client/src/pages/PeoplePage.tsx +++ b/client/src/pages/PeoplePage.tsx @@ -9,9 +9,12 @@ import { fetchEmployees, fetchEmployeeOrganization, regularizeEmployee, + retryEmployeeWecomDirectorySync, startEmployeeOffboarding, startEmployeeOnboarding, type EmployeeEmploymentStatus, + type EmployeeSummary, + type WecomDirectoryState, type OrganizationTreeNode, } from "../api/employees"; import { exportEmployees } from "../api/exports"; @@ -59,6 +62,34 @@ function employmentStatusLabel(status: EmployeeEmploymentStatus | null) { 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) { for (const node of nodes) { if (!target.has(node.id)) { @@ -93,6 +124,7 @@ export function PeoplePage() { const [managerDialogOpen, setManagerDialogOpen] = useState(false); const [enableConfirmOpen, setEnableConfirmOpen] = useState(false); const [disableConfirmOpen, setDisableConfirmOpen] = useState(false); + const [offboardingTarget, setOffboardingTarget] = useState(null); const [targetDepartmentId, setTargetDepartmentId] = useState(""); const [targetManagerId, setTargetManagerId] = useState(""); @@ -161,6 +193,7 @@ export function PeoplePage() { mutationFn: (employeeId: string) => startEmployeeOffboarding(employeeId), onSuccess: async () => { await invalidatePeopleQueries(); + setOffboardingTarget(null); toast.success("已发起离职流程"); }, 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({ mutationFn: (format: "csv" | "xlsx") => exportEmployees({ @@ -266,8 +319,9 @@ export function PeoplePage() { transferDepartmentMutation.isPending || setManagerMutation.isPending || batchEnableMutation.isPending || - batchDisableMutation.isPending; - const tableColumnCount = canManageUsers ? 7 : 6; + batchDisableMutation.isPending || + wecomDirectoryRetryMutation.isPending; + const tableColumnCount = canManageUsers ? 8 : 7; function toggleSelection(employeeId: string, checked: boolean) { setSelectedEmployeeIds((current) => { @@ -470,78 +524,105 @@ export function PeoplePage() { 岗位 / 雇用状态 角色 账号状态 + 企业微信 操作 {items.length ? ( - items.map((item) => ( - - {canManageUsers ? ( + items.map((item) => { + const wecomView = wecomDirectoryStatusView(item.wecomDirectory); + return ( + + {canManageUsers ? ( + + toggleSelection(item.id, event.target.checked)} + /> + + ) : null} - toggleSelection(item.id, event.target.checked)} - /> - - ) : null} - -
- -
-
{item.name}
-
- {item.employeeNo} · {item.email} +
+ +
+
{item.name}
+
+ {item.employeeNo} · {item.email} +
-
- - -
-
{item.department?.name ?? "-"}
-
- 上级:{item.directManager ? `${item.directManager.name} · ${item.directManager.employeeNo}` : "-"} + + +
+
{item.department?.name ?? "-"}
+
+ 上级:{item.directManager ? `${item.directManager.name} · ${item.directManager.employeeNo}` : "-"} +
-
- - -
-
{item.title || "-"}
-
- {employmentStatusLabel(item.employmentStatus)} · 入职 {item.hireDate ?? "-"} + + +
+
{item.title || "-"}
+
+ {employmentStatusLabel(item.employmentStatus)} · 入职 {item.hireDate ?? "-"} +
-
- - {item.roleCodes.join(" / ") || "-"} - - - - -
- - {canManageUsers ? ( - <> - - - - - ) : null} -
-
- - )) + + {item.roleCodes.join(" / ") || "-"} + + + + +
+ +
{wecomView.hint}
+
+
+ +
+ + {canManageUsers ? ( + <> + + + {shouldShowWecomRetry(item.wecomDirectory) ? ( + + ) : null} + + + ) : null} +
+
+ + ); + }) ) : ( @@ -657,6 +738,26 @@ export function PeoplePage() { await batchDisableMutation.mutateAsync(); }} /> + + { + if (!open) setOffboardingTarget(null); + }} + title="发起离职流程" + description={ + offboardingTarget + ? `将为 ${offboardingTarget.name} 发起离职清单;清单完成后会禁用 OA 账号并尝试禁用企业微信成员。` + : "" + } + confirmLabel={offboardingMutation.isPending ? "处理中..." : "确认发起"} + variant="destructive" + onConfirm={async () => { + if (offboardingTarget) { + await offboardingMutation.mutateAsync(offboardingTarget.id); + } + }} + />
); } diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index 9d69c34d..3e045bf2 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -68,6 +68,9 @@ WECOM_REDIRECT_URI=https://oa.example.com/api/auth/wecom/callback WECOM_ALLOWED_RETURN_HOSTS=oa.example.com WECOM_CHECKIN_SYNC_ENABLED=false WECOM_CHECKIN_SECRET= +WECOM_CONTACT_SYNC_ENABLED=false +WECOM_CONTACT_SECRET= +WECOM_CONTACT_DEFAULT_DEPARTMENT_ID= IM_NOTIFIER_ENABLED=true # ===== 数据库(Compose 内部连接) ===== diff --git a/docs/trialrun/人工试运行报告.md b/docs/trialrun/人工试运行报告.md index 8b66df26..18e65db0 100644 --- a/docs/trialrun/人工试运行报告.md +++ b/docs/trialrun/人工试运行报告.md @@ -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 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 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` | | 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` | +| 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` | ## 当前门禁 diff --git a/docs/文档事实源状态矩阵.md b/docs/文档事实源状态矩阵.md index b589a86c..9085e224 100644 --- a/docs/文档事实源状态矩阵.md +++ b/docs/文档事实源状态矩阵.md @@ -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` | | `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` | +| `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。 @@ -65,9 +66,9 @@ | 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 | 判断当前该读什么 | -| `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 | 判断文档权威性 | -| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8` | Issue CSV | `docs/requirements-specification.md` | 2026-05-13 | `rg` | high | 定义做什么、不做什么和验收口径 | +| `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,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,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 | 记录已拍板产品边界 | | `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 和交付规则 | @@ -78,7 +79,7 @@ | `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 和发票边界 | | `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 验证 | | `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 | 员工侧使用说明 | @@ -171,4 +172,4 @@ - `ME6-3` 代表性企业微信真机门禁已关闭。 - 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 权限。 diff --git a/docs/文档索引.md b/docs/文档索引.md index b7462058..821f392a 100644 --- a/docs/文档索引.md +++ b/docs/文档索引.md @@ -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 已完成并部署。 - 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。 - 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 统一考勤入口完成态。 +- 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 或人脸信息 | | `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、不导入银行流水;企微打卡同步外部配置仍需后台配置 | +| `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,旧文件保留历史审计语义。 @@ -162,7 +164,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M | 文档 | 说明 | | --- | --- | | [架构概览](./架构概览.md) | 当前架构和运行时边界 | -| [集成边界](./集成边界.md) | ERP、IM、AI、MCP、PWA 集成边界;不扩大当前 MVP 范围 | +| [集成边界](./集成边界.md) | 历史 ERP/IM/AI/MCP/PWA 集成边界;HRWD 后 ERP 未来不接入 OA,不扩大当前 MVP 范围 | | [领域事件清单](./领域事件清单.md) | 领域事件名和对外事件边界 | | [MCP Tools](./mcp-tools.md) | 脚本和 AGENTS 强引用,保留英文文件名 | | [非功能基线](./ops/非功能基线.md) | 安全、性能、回滚、上线最低要求 | diff --git a/docs/海音达OA-MVP需求规格说明书.md b/docs/海音达OA-MVP需求规格说明书.md index 00fd457a..6cd20302 100644 --- a/docs/海音达OA-MVP需求规格说明书.md +++ b/docs/海音达OA-MVP需求规格说明书.md @@ -83,7 +83,7 @@ | 当前已实现 / 已收口 | R6 小范围试运行主链路;PC6/PA6/FL6/AP6/RI6/LP6;ME6 自动化、公网和代表性真机门禁;MIF7/MIF7T 移动信息架构与真机反馈整改;MIF7TD 公网部署、匿名复检和带凭据 live smoke/OCR。 | | R7 必需 | 用户确认参与人、角色、时间窗口、回滚联系人、问题登记负责人和每日收口节奏;R7E 公网健康、public bundle、带凭据 live readiness、公网移动矩阵、核心业务流和账号角色只读门禁通过;阻断问题为 0。 | | R7 后优化 | UX8 真实反馈池、iPhone 12 间距观察项、详情字段扫读性二次优化、移动采购创建能力评估、组织账号主数据治理、图片 OCR 能力、转审扩展和邮件通道完整验收。 | -| 明确不做 / 当前冻结 | ERP 实时集成、AI 自动处理、完整差旅、复杂 BPM、审批设计器、预算重型控制、企业微信原生审批;任何一项启动都必须另建里程碑和 Issue CSV。 | +| 明确不做 / 当前冻结 | ERP 未来不接入 OA;AI 自动处理、完整差旅、复杂 BPM、审批设计器、预算重型控制、企业微信原生审批继续不进入当前路线;任何一项业务扩张都必须另建里程碑和 Issue CSV。 | ### 2.3 P2T 候选优先级 @@ -91,13 +91,13 @@ P2T 只做排序和边界确认,不进入当前实现。候选项按 R7 真实 | 优先级 | 候选 | 当前边界 | | --- | --- | --- | -| P2-1 | 组织与账号主数据治理 | 优先覆盖直属主管缺失、主管/审批人停用、admin-only 误用、财务/管理员角色混淆、新员工账号和企业微信绑定;不直接写生产账号数据 | +| P2-1 | 人事生命周期与账号主数据治理 | 优先覆盖入职、转正、调岗、离职、直属主管缺失、主管/审批人停用、admin-only 误用、财务/管理员角色混淆、OA 账号和企业微信通讯录/绑定闭环;不使用企业微信原生审批 | | P2-2 | 审批与通知扩展 | 转审扩展到请假/采购/差旅拆 `W2F`;邮件通道和三渠道完整验收另决策;不引入复杂 BPM | | P2-3 | OCR 与票据增强 | 当前试运行继续 PDF-only;图片 OCR、批量样票和异步体验需另补样票、隐私处理和 provider 回归 | -| P2-4 | 费用/预算/项目轻量增强 | 可评估费用提示和项目关联增强;预算重型控制、ERP 实时写回和正式成本核算继续冻结 | +| P2-4 | 费用/预算/项目轻量增强 | 可评估费用提示和项目关联增强;预算重型控制和正式成本核算继续冻结,ERP 不再作为后续接入项 | | 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 | 企业微信基础设施 | 手机入口、免登、通知、打卡原始采集 | 企业微信自建应用 `海音达工作台` | 手机进入 `/mobile`,通知 deeplink 回 OA | +| P0 | 人事生命周期 | 入职、转正、调岗、离职、OA 账号、权限和企业微信通讯录状态闭环 | 人员管理、员工档案 | OA 为员工主事实源,企微通讯录同步成功或可解释 SKIP/失败可重试 | | P0 | 报销/发票/OCR | 员工自助报销,财务可查重和导出 | 报销、移动报销、票据中心 | 上传、OCR、查重、绑定、审批、付款闭环 | | P0 | 请假与考勤修正审批 | 请假、补卡、外出、加班等修正都在 OA 内审批 | 请假、移动审批、团队状态 | 审批后团队状态和考勤口径不破坏 | | P1 | 采购/费用审批 | 小团队采购和费用事项留痕 | 采购、审批待办 | 申请、附件、审批、状态更新可用 | | P1 | 合同与附件归档 | 管合同编号、责任人、归档位置和非涉密附件 | 合同/项目资料或项目页 | 能登记、查询、关联项目和审批记录 | | P1 | 项目轻管理 | 把费用、采购、差旅、周报挂到项目 | 项目、Portal、周报 | 能看项目事项、风险、费用摘要,不做重型排产 | -| P2 | 预算/支付/ERP/AI | 在试运行稳定后增强运营能力 | 管理员备用入口 | 另立 Issue CSV,不进入首批验收 | +| P2 | 预算/支付/AI | 在试运行稳定后按真实反馈增强运营能力 | 管理员备用入口 | 另立 Issue CSV,不进入首批验收;ERP 不进入路线 | --- @@ -406,7 +407,7 @@ REQAUD 复核后,ATTV1 已按“企业微信打卡、OA 审批解释”口径 - 企业微信打卡 live 同步配置和权限稳定化。 - 补卡、外出、加班的真实使用反馈体验优化。 - 合同台账、合同编号、合同审批和离线档案号的细化。 -- 预算控制、支付批次、AP 运营和 ERP 出站凭证增强。 +- 预算控制和付款/AP 运营真实使用反馈优化;ERP 不再规划出站凭证或实时集成。 - 项目轻管理与周报、待办、费用、采购、差旅的进一步联动。 - 全量 E2E 稳定性清理和移动视觉回归。 - 生产备份恢复演练、并发试运行和正式上线复核。 diff --git a/docs/管理员手册.md b/docs/管理员手册.md index 64c71c6e..e7b9583a 100644 --- a/docs/管理员手册.md +++ b/docs/管理员手册.md @@ -1,11 +1,11 @@ # 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. 适用范围 - 适用于 `small_team_internal` 工作区模式。 - 适用于单公司、少部门、全员账号密码登录的小团队。 -- 目标是把内部高频管理动作收口到 `hyb-oa`;企业微信只作为入口、身份、通知和打卡底座。 +- 目标是把内部高频管理动作收口到 `hyb-oa`;企业微信只作为入口、通讯录账号、身份、通知和打卡底座。 ## 2. 管理员每周固定动作 - 周一:查看 `审批待办`、报销/OCR 异常、请假/补卡/外出/加班审批、项目风险和本周关键付款/归档事项。 @@ -15,7 +15,7 @@ ## 3. 首次上线最小配置 1. 进入 `工作区设置`,确认产品模式为 `small_team_internal`。 2. 首批主路径优先保留:`Portal`、`审批待办`、`报销`、`票据/OCR`、`请假`、`项目`、`采购/费用`、`合同/附件`。 -3. `团队状态`、`周报`、`待办`、`公告`、`人员`、`资产` 可按真实试运行需要开启;`支付批次` 和 `AP 运营` 已作为财务闭环正式能力面向出纳、财务和管理员开放,普通员工不显示该入口;`预算`、`ERP`、`AI`、完整差旅能力仍作为备用或历史能力。 +3. `团队状态`、`周报`、`待办`、`公告`、`人员`、`资产` 可按真实试运行需要开启;`支付批次` 和 `AP 运营` 已作为财务闭环正式能力面向出纳、财务和管理员开放,普通员工不显示该入口;`预算`、`AI`、完整差旅能力仍作为备用或历史能力,ERP 不再规划接入。 4. 在 `用户管理` 中按推荐预设配置:老板 / 管理员 / 主管 / 员工。 - 正式审批组织口径:`majianyong` 配置为员工可见直属主管和 `manager`;`wangshunli` 配置为后台公司负责人 `ceo + admin`,不配置为普通 `manager`。 - 普通员工和专用测试员工的直属主管配置为 `majianyong`;`majianyong` 的直属领导配置为 `wangshunli`。 @@ -23,12 +23,12 @@ - 缺直属主管、主管停用、主管链断裂或审批节点解析到申请人本人时,应先修正组织或审批规则,不要用管理员账号代批。 5. 检查功能开关: - 建议开启:`portal_home_v1`、企业微信入口相关开关、报销/OCR、审批待办、请假、项目基础能力和 `payment_batch_v1`。 - - 按需开启:`announcements_v1`、`presence_board_v1`、`weekly_report_v1`、`employee_lifecycle_v1`、`todo_board_v1`、`global_search_v1`。 - - 建议保留备用:`cutover_console_v1`、预算、ERP、AI 和完整差旅相关开关。 + - 按需开启:`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`、预算、AI 和完整差旅相关开关;`erp_sync` 保持关闭且不再作为后续接入目标。 ### 3.1 ORG8 组织账号与角色边界 -OA 员工账号是权限、审批、业务数据和审计的主事实源;企业微信身份只作为入口、免登和通知辅助。管理员维护组织账号时,先保证员工主数据完整,再配置角色和企业微信绑定。 +OA 员工账号是权限、审批、业务数据和审计的主事实源;企业微信作为通讯录账号、入口、免登、通知和打卡采集基础设施。管理员维护组织账号时,先保证 OA 员工主数据完整,再配置角色、企业微信通讯录同步和绑定状态。 最小必填主数据: @@ -39,7 +39,7 @@ OA 员工账号是权限、审批、业务数据和审计的主事实源;企 | 直属主管 | 请假、报销、采购等默认审批链 | 审批人缺失或错误回退 | | 账号状态 | 登录、入口、通知和禁用拦截 | 离职或停用人员可能继续进入 | | 业务角色 | 审批、财务、HR、系统管理入口 | 权限过大或无法处理待办 | -| 企业微信绑定状态 | 手机入口、免登、通知 deeplink | 企业微信入口无法直达或误绑定 | +| 企业微信通讯录/绑定状态 | 手机入口、免登、通知 deeplink、打卡采集身份 | 企业微信入口无法直达、误绑定或离职后未禁用 | 角色边界: @@ -57,8 +57,8 @@ OA 员工账号是权限、审批、业务数据和审计的主事实源;企 - `admin-only` 账号不得作为报销、请假或采购的业务审批人。 - 财务和系统管理员混授属于高风险组合,必须有人工确认、审计记录和可回退路径。 - 直属主管、审批人或财务账号停用后,不得继续接收新待办;历史审批记录保留只读。 -- 新员工账号创建必须同时确认直属主管、初始角色、账号状态和企业微信绑定路径。 -- 离职或停用账号必须阻断登录、企业微信入口和新业务处理,但不得删除历史单据和审批记录。 +- 新员工账号创建必须同时确认直属主管、初始角色、账号状态、企业微信通讯录同步状态和绑定路径。 +- 离职或停用账号必须阻断登录、企业微信入口和新业务处理;离职完成后默认禁用企业微信成员,不删除成员,不删除历史单据和审批记录。 只读审计口径: @@ -236,24 +236,27 @@ ORDER BY e.employee_no; - `payment_batch_v1` 意外关闭,或 `erp_sync`、图片 OCR、真实支付、银行流水导入被误开启。 - 同一天出现多个高优先级问题且无法解释清楚。 -首周不主动新增可选功能。UX8 只接真实反馈或自动化确定性失败;移动采购创建、通知扩展、合同/供应商/资产、图片 OCR、ERP、真实支付、银行流水导入、复杂 BPM、审批设计器和重预算都必须另建独立合同。 +首周不主动新增可选功能。UX8 只接真实反馈或自动化确定性失败;移动采购创建、通知扩展、合同/供应商/资产、图片 OCR、真实支付、银行流水导入、复杂 BPM、审批设计器和重预算都必须另建独立合同;ERP 不再作为后续合同候选。 ## 4. 人员生命周期管理 ### 4.1 入职 1. 在 `人员管理` 创建员工账号。 2. 打开员工详情页,补齐部门、职位、直属上级、试用期结束日、工作地点、紧急联系人。 -3. 发起 `入职`,系统会自动生成 checklist:账号初始化、资产发放、制度确认。 -4. checklist 全部完成后,员工状态自动从 `onboarding` 进入 `probation`。 +3. 若已开启 `hr_wecom_directory_sync_v1` 且企业微信通讯录配置完整,创建或发起入职时系统会尝试创建企业微信成员;缺 Secret、缺默认部门或无通讯录权限时记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`,OA 入职不回滚。 +4. 发起 `入职`,系统会自动生成 checklist:账号初始化、资产发放、制度确认。 +5. checklist 全部完成后,员工状态自动从 `onboarding` 进入 `probation`。 +6. 人员列表或员工详情中的“企业微信通讯录”状态若为同步失败或配置缺失,管理员可在配置修复后执行“重试同步”。 ### 4.2 转正 / 调岗 - `转正`:记录事件并将员工状态改为 `active`。 -- `调岗`:记录部门、直属上级、职位调整,同时生成调岗交接 checklist。 +- `调岗`:记录部门、直属上级、职位调整,同时生成调岗交接 checklist;若通讯录同步已开启,会尝试更新企业微信成员资料。 - 这两个动作只做管理留痕,不引入复杂审批流。 ### 4.3 离职 1. 发起 `离职` 后,系统自动生成离职 checklist:资产归还、权限回收、费用与审批清理、交接说明。 2. 若员工仍有名下资产或待处理审批任务,系统不会自动完成离职。 -3. checklist 完成且阻塞清零后,账号会被停用,主业务入口对该账号不可再用。 +3. checklist 完成且阻塞清零后,OA 账号会被停用并吊销现有会话,主业务入口对该账号不可再用。 +4. 离职完成后系统默认尝试禁用企业微信成员,不删除企业微信成员;同步失败时保留记录并允许管理员重试。 ## 5. 团队状态与考勤口径 - 员工每天可在 `Portal` 或 `团队` 页更新状态:在岗 / 居家 / 外出 / 出差。 @@ -283,7 +286,7 @@ ORDER BY e.employee_no; ## 8. 备用模块治理 - 采购/费用、合同/附件和项目轻管理属于 P1 轻量能力,可在 P0 审批、报销/OCR、请假和企业微信入口稳定后逐步开放。 - 支付批次和 AP 运营已作为正式财务闭环开放给出纳、财务和管理员;普通员工仍不显示财务运营入口。 -- 预算、ERP、AI、完整差旅、资产深度管理等保留代码和历史数据,但不作为当前 MVP 首批主路径。 +- 预算、AI、完整差旅、资产深度管理等保留代码和历史数据,但不作为当前 MVP 首批主路径;ERP 保持关闭,不再规划接入。 - 若短期完全不用,保持管理员可见即可,不必删除。 - 只有出现真实场景时,再恢复到主导航或单独扩展。 @@ -293,7 +296,7 @@ ORDER BY e.employee_no; 3. 同步启用付款批次、AP 台账和银行回单附件,付款动作仍由出纳在 OA 外通过银行渠道完成。 4. 同步启用已完成的 `请假`、补卡、外出、加班 OA 审批和月度考勤解释;企业微信打卡仍作为原始采集和风控底座。 5. 在 P0 稳定后开放 P1:`采购/费用审批`、`合同/附件归档`、`项目轻管理`。 -6. 最后按真实需要评估周报、团队状态、公告、人员生命周期、资产、预算、ERP、AI 和完整差旅。 +6. 最后按真实需要评估周报、团队状态、公告、人员生命周期、资产、预算、AI 和完整差旅;ERP 不进入评估队列。 ## 10. 试点验收口径 - 全员都能账号密码登录;已绑定员工能从企业微信 `海音达工作台` 进入移动工作台。 @@ -303,7 +306,7 @@ ORDER BY e.employee_no; - 至少 1 个审批待办从企业微信提醒 deeplink 回到 OA 页面处理。 - P1 试点时,至少 1 条采购/费用申请、1 条合同台账、1 个项目关联事项可创建、审批或归档。 - 正式使用前至少完成一次备份可用性核对;真实恢复演练必须另建批准合同,并记录恢复后的 `/ready` 结果。 -- 普通员工主导航不出现预算、AP、ERP、AI、完整差旅等当前首批不开放的备用模块。 +- 普通员工主导航不出现预算、AP、AI、完整差旅等当前首批不开放的备用模块;ERP 入口保持关闭。 ## 11. 4 周观测操作法 1. 每周一进入 `Portal`、`审批待办` 和 `考勤` 查看近 7 天登录覆盖率、审批积压、报销/OCR 异常、请假/考勤修正审批和企业微信入口错误;企微打卡同步缺配置时记录为 `SKIP`。 @@ -383,7 +386,7 @@ ORDER BY e.employee_no; - `APP_ORIGIN=https://oa.example.com` - `COOKIE_SECURE=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`,不要在公网生产暴露文档入口。 5. 定期检查审计日志中的登录失败、Refresh 来源不匹配与涉密资料查看记录。 6. 准备独立 smoke 账号,不使用老板、管理员或真实员工日常账号跑自动化;smoke 账号的角色和直属上级应按正式审批链配置。 diff --git a/docs/集成边界.md b/docs/集成边界.md index fff87abf..bb05612a 100644 --- a/docs/集成边界.md +++ b/docs/集成边界.md @@ -4,8 +4,8 @@ **快照日期**: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 直达、本地会话快照离线守卫与审批草稿重放。 **目标**:在保持核心域稳定的前提下,冻结 ERP / IM / AI / MCP / TRAVEL / PWA 的长期边界,并记录 IM 一期、ERP 三期、AI 一期、差旅三期与 PWA 两期的已实现范围、禁入规则以及 R10 主线闭环后的固定收口结论。 -**当前路线说明**:2026-05-09 之后首批 MVP 固定为 `hyb-oa` 唯一审批中心 + 企业微信基础设施。飞书路线已废弃;钉钉、小程序、外部 IM 回调审批和付费第三方套件不进入首批。 -**不得误读**:本文是技术参考和历史证据,不作为当前 MVP 扩大 ERP、AI、TRAVEL、PWA 或外部 IM 适配范围的依据;任何重新启用都必须先更新 PRD 或模块规约并新建 Issue CSV。 +**当前路线说明**:2026-05-17 HRWD 后,ERP 从“冻结待办”调整为“未来不接入 OA”;财务闭环固定为 OA 内审批/台账/付款批次/银行回单 + 月度代账交接。2026-05-09 之后首批 MVP 固定为 `hyb-oa` 唯一审批中心 + 企业微信基础设施。飞书路线已废弃;钉钉、小程序、外部 IM 回调审批和付费第三方套件不进入首批。 +**不得误读**:本文是技术参考和历史证据,不作为当前 MVP 扩大 ERP、AI、TRAVEL、PWA 或外部 IM 适配范围的依据;ERP 相关历史实现和接口不代表后续路线,不再规划对接外部 ERP。 ## 1. 总体原则 diff --git a/docs/需求-企业微信基础设施规约.md b/docs/需求-企业微信基础设施规约.md index 4d12d284..32bb844e 100644 --- a/docs/需求-企业微信基础设施规约.md +++ b/docs/需求-企业微信基础设施规约.md @@ -14,7 +14,7 @@ ### 1.1 目标 -- 企业微信作为 `hyb-oa` 的手机工作台入口、身份辅助、消息通知和打卡原始采集底座。 +- 企业微信作为 `hyb-oa` 的手机工作台入口、通讯录账号、身份辅助、消息通知和打卡原始采集底座。 - 员工在手机企业微信中进入唯一自建应用 `海音达工作台`,默认落到 OA 移动端。 - 企业微信消息只做提醒和跳转,审批动作、业务填写、附件查看和正式留痕都在 OA 内完成。 - 企业微信打卡只作为原始采集来源,后续结合 OA 请假、补卡、外出、加班审批形成正式考勤口径。 @@ -35,11 +35,11 @@ | --- | --- | --- | --- | | 员工 | 从手机进入 OA、接收提醒、完成绑定 | 进入移动工作台、绑定企业微信身份、点击消息 deeplink、查看本人相关业务 | 不能通过企业微信绕过 OA 权限 | | 审批人 | 在手机端收到待办提醒并回 OA 处理 | 点击通知进入 OA 审批页、在 OA 内审批 | 不能在企业微信原生审批里完成正式审批 | -| 管理员/HR | 管理入口、人员、绑定异常和离职停用 | 配置可见范围、协助绑定、处理离职停用、查看通知失败 | 不得手工把一个企业微信身份绑定到多个员工 | +| 管理员/HR | 管理入口、人员、通讯录同步、绑定异常和离职停用 | 配置可见范围、协助绑定、触发通讯录创建/更新/禁用、处理离职停用、查看通知失败 | 不得手工把一个企业微信身份绑定到多个员工;通讯录同步失败不得回滚 OA 人事主数据 | | 系统管理员 | 维护企业微信应用配置和消息通道 | 配置 CorpID、AgentID、回调、可信域名、消息开关和重试策略 | 不处理业务审批,不暴露 secret | | 财务/老板 | 接收关键待办或状态提醒 | 点击 deeplink 回 OA 查看或处理 | 消息中不得出现完整发票、合同正文、敏感报价、银行卡、身份证 | -企业微信身份只是登录和通知辅助。后端 OA 账号、角色、数据范围和审批任务仍是权限单一事实源。 +OA 是员工、账号、角色、数据范围、审批任务和审计的主事实源。企业微信身份和通讯录账号服务于入口、通知、打卡采集和移动工作台,不替代 OA 权限判断。 --- @@ -141,10 +141,21 @@ deeplink 必须校验目标地址白名单,避免跳转到非 OA 域名或未 ## 6. 通讯录身份边界 -- 企业微信通讯录可作为员工姓名、部门、手机号、邮箱、在职状态的辅助来源。 -- OA 员工表仍是权限、审批、业务数据和审计的主事实源。 -- 首批不要求自动全量同步通讯录;可先支持管理员手工维护员工和绑定关系。 -- 后续若接通讯录同步,必须处理新增、改名、调岗、离职、手机号变更和部门变更,并保留同步日志。 +- HRWD 起,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 内留痕。 | --- diff --git a/issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv b/issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv new file mode 100644 index 00000000..7aedd3b5 --- /dev/null +++ b/issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv @@ -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,避免部署重置付款批次正式常开状态。" diff --git a/server/.env.example b/server/.env.example index 2455009e..43e8ae6b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -55,6 +55,9 @@ MAX_UPLOAD_SIZE_BYTES="10485760" # WECOM_AGENT_SECRET="" # WECOM_CHECKIN_SYNC_ENABLED="false" # WECOM_CHECKIN_SECRET="" +# WECOM_CONTACT_SYNC_ENABLED="false" +# WECOM_CONTACT_SECRET="" +# WECOM_CONTACT_DEFAULT_DEPARTMENT_ID="" DELEGATION_REVOKE_CRON="0 * * * *" @@ -124,4 +127,5 @@ PRESENCE_BOARD_ENABLED=false ATTENDANCE_UNIFIED_ENABLED=false WEEKLY_REPORT_ENABLED=false EMPLOYEE_LIFECYCLE_ENABLED=false +WECOM_CONTACT_SYNC_ENABLED=false TODO_BOARD_ENABLED=false diff --git a/server/prisma/migrations/20260517200040_hr_wecom_directory_v1/migration.sql b/server/prisma/migrations/20260517200040_hr_wecom_directory_v1/migration.sql new file mode 100644 index 00000000..deb95550 --- /dev/null +++ b/server/prisma/migrations/20260517200040_hr_wecom_directory_v1/migration.sql @@ -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; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index ba2e14c2..9fc4db1c 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -620,6 +620,8 @@ model Employee { leave_ledgers LeaveLedger[] wecom_checkin_records WecomCheckinRecord[] 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[] owned_cost_centers CostCenter[] @relation("CostCenterOwner") purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant") @@ -1991,6 +1993,31 @@ model WecomCheckinRecord { @@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 { id String @id @default(uuid()) @db.Uuid owner_id String @db.Uuid diff --git a/server/src/config.js b/server/src/config.js index a320a4b6..22c121ea 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -62,6 +62,9 @@ function getConfig() { wecomAgentSecret: process.env.WECOM_AGENT_SECRET ?? "", wecomCheckinSyncEnabled: process.env.WECOM_CHECKIN_SYNC_ENABLED === "true", 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", wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "", wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "", diff --git a/server/src/routes/employees.js b/server/src/routes/employees.js index 3b7f36ed..9b330d78 100644 --- a/server/src/routes/employees.js +++ b/server/src/routes/employees.js @@ -12,6 +12,13 @@ const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); +const { + getLatestWecomDirectorySync, + mapWecomDirectoryState, + mapWecomDirectorySyncRun, + normalizeOperation, + syncWecomDirectoryForEmployee, +} = require("../services/integrations/wecomDirectory"); const { listOrganizationDepartments } = require("../services/organization"); 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(), }).strict(); +const WECOM_DIRECTORY_RETRY_SCHEMA = z.object({ + operation: z.enum(["create", "update", "disable"]).optional(), +}).strict(); + const CONTRACT_CREATE_SCHEMA = z.object({ contractNo: z.string().trim().min(1).max(100), 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, "*"); } +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) { 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 } }, department: { select: { id: true, name: 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" }] }), @@ -584,6 +646,7 @@ async function buildEmployeeProfile(prisma, employeeId) { avatarUrl: buildAvatarUrl(employee.direct_manager.id, employee.direct_manager.avatar_path), } : null, + wecomDirectory: mapWecomDirectoryState(employee), }, lifecycle: { events: lifecycleEvents.map(mapLifecycleEvent), @@ -745,6 +808,90 @@ async function employeesRoutes(fastify) { 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) => { await ensureFeatureEnabled(fastify, request); const employeeId = parseId(request.params?.id); @@ -1176,8 +1323,18 @@ async function employeesRoutes(fastify) { 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); - return sendData(reply, request, profile); + return sendData(reply, request, { ...profile, wecomDirectorySync: directorySync }); }); 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 })); await tx.employeeChecklistTask.createMany({ data: checklist, skipDuplicates: false }); }); + const directorySync = await trySyncWecomDirectory(fastify, request, { + employeeId, + operation: "create", + }); 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) => { @@ -1258,8 +1419,12 @@ async function employeesRoutes(fastify) { notes: body.notes, }); }); + const directorySync = await trySyncWecomDirectory(fastify, request, { + employeeId, + operation: "update", + }); 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) => { @@ -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); - 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) => { diff --git a/server/src/routes/featureFlags.js b/server/src/routes/featureFlags.js index 6a7ed674..ced2049f 100644 --- a/server/src/routes/featureFlags.js +++ b/server/src/routes/featureFlags.js @@ -268,6 +268,15 @@ const FEATURE_FLAG_BACKEND_IMPACT = Object.freeze({ 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: [ { label: "待办工作台", diff --git a/server/src/routes/users.js b/server/src/routes/users.js index 3e521ffe..350f481e 100644 --- a/server/src/routes/users.js +++ b/server/src/routes/users.js @@ -12,6 +12,10 @@ const { } = require("../services/employeeDirectoryFilters"); const { applyEmployeeTransfer } = require("../services/employeeLifecycle"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); +const { + mapWecomDirectoryState, + syncWecomDirectoryForEmployee, +} = require("../services/integrations/wecomDirectory"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); @@ -53,6 +57,7 @@ const createBodySchema = z.object({ hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), roles: z.array(roleCodeSchema).min(1).optional(), password: z.string().trim().min(1).optional(), + syncWecomDirectory: z.boolean().optional(), }); const updateBodySchema = z.object({ @@ -218,6 +223,7 @@ function mapEmployeeSummary(row) { hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null, avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null, roleCodes: row.roles.map((item) => item.role_code), + wecomDirectory: mapWecomDirectoryState(row), createdAt: row.created_at?.toISOString?.() ?? null, updatedAt: row.updated_at?.toISOString?.() ?? null, }; @@ -445,6 +451,48 @@ async function setEmployeeAccountStatus(tx, request, options) { 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() { const password = `ResetA1${crypto.randomBytes(6).toString("hex")}`; const errors = validatePassword(password); @@ -978,6 +1026,13 @@ async function usersRoutes(fastify, opts = {}) { department: { select: { id: true, name: true } }, direct_manager: { select: { id: true, employee_no: true, name: 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 }), @@ -1033,6 +1088,13 @@ async function usersRoutes(fastify, opts = {}) { created_at: true, updated_at: 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 }, select: { role_code: true }, }); + const directorySync = + body.syncWecomDirectory === false + ? null + : await trySyncWecomDirectory(fastify, request, { + employeeId: created.employee.id, + operation: "create", + }); return sendData(reply, request, { ...mapEmployeeSummary({ ...created.employee, roles }), 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, { summary: { total: body.employeeIds.length, updated: body.employeeIds.length }, 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, { summary: { total: body.employeeIds.length, updated: body.employeeIds.length }, 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, { summary: { total: body.employeeIds.length, updated: body.employeeIds.length }, 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, { summary: { total: body.employeeIds.length, updated: body.employeeIds.length }, employeeIds: body.employeeIds, @@ -1459,7 +1545,21 @@ async function usersRoutes(fastify, opts = {}) { 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, updated_at: 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) { @@ -1589,6 +1696,13 @@ async function usersRoutes(fastify, opts = {}) { created_at: true, updated_at: 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; } + 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( reply, 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, updated_at: 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) { @@ -1783,6 +1917,13 @@ async function usersRoutes(fastify, opts = {}) { created_at: true, updated_at: 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; }); + const directorySync = await trySyncWecomDirectory(fastify, request, { + employeeId: userId, + operation: "disable", + }); return sendData(reply, request, { id: employee.id, status: employee.status, + wecomDirectorySync: directorySync, }); }, ); @@ -1903,10 +2049,15 @@ async function usersRoutes(fastify, opts = {}) { now, }); }); + const directorySync = await trySyncWecomDirectory(fastify, request, { + employeeId: userId, + operation: "disable", + }); return sendData(reply, request, { id: employee.id, status: employee.status, + wecomDirectorySync: directorySync, }); }, ); @@ -1928,10 +2079,15 @@ async function usersRoutes(fastify, opts = {}) { now, }); }); + const directorySync = await trySyncWecomDirectory(fastify, request, { + employeeId: userId, + operation: "update", + }); return sendData(reply, request, { id: employee.id, status: employee.status, + wecomDirectorySync: directorySync, }); }, ); diff --git a/server/src/scripts/seed-config.js b/server/src/scripts/seed-config.js index c7e29998..75491787 100644 --- a/server/src/scripts/seed-config.js +++ b/server/src/scripts/seed-config.js @@ -317,6 +317,13 @@ const DEFAULT_FEATURE_FLAGS = [ department_ids: [], 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", override_mode: "force_off", @@ -588,17 +595,8 @@ async function seedFeatureFlags(prisma) { const now = new Date(); for (const item of DEFAULT_FEATURE_FLAGS) { - await prisma.featureFlag.upsert({ - where: { flag_key: item.flag_key }, - 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: { + await prisma.featureFlag.create({ + data: { flag_key: item.flag_key, override_mode: item.override_mode, rollout_type: item.rollout_type, @@ -609,6 +607,11 @@ async function seedFeatureFlags(prisma) { updated_at: now, updated_by: null, }, + }).catch((error) => { + if (error?.code === "P2002") { + return null; + } + throw error; }); } } diff --git a/server/src/services/featureFlags/index.js b/server/src/services/featureFlags/index.js index 40d76a41..60c82265 100644 --- a/server/src/services/featureFlags/index.js +++ b/server/src/services/featureFlags/index.js @@ -15,6 +15,7 @@ const FEATURE_FLAG_KEYS = { attendanceUnified: "attendance_unified_v1", weeklyReport: "weekly_report_v1", employeeLifecycle: "employee_lifecycle_v1", + hrWecomDirectorySync: "hr_wecom_directory_sync_v1", todoBoard: "todo_board_v1", wecomEntry: "wecom_entry_v1", projectDomain: "project_domain_v1", @@ -140,6 +141,10 @@ function resolveEnvGate(config, flagKey) { return Boolean(config.employeeLifecycleEnabled); } + if (flagKey === FEATURE_FLAG_KEYS.hrWecomDirectorySync) { + return Boolean(config.wecomContactSyncEnabled); + } + if (flagKey === FEATURE_FLAG_KEYS.todoBoard) { return Boolean(config.todoBoardEnabled); } diff --git a/server/src/services/integrations/wecomAccessToken.js b/server/src/services/integrations/wecomAccessToken.js index b8a967c7..d43e5111 100644 --- a/server/src/services/integrations/wecomAccessToken.js +++ b/server/src/services/integrations/wecomAccessToken.js @@ -50,9 +50,10 @@ async function getWecomAppAccessToken({ config, timeoutMs = 5000, fetchImpl = fe const corpId = toSafeString(config?.wecomCorpId, 128); const agentId = toSafeString(config?.wecomAgentId, 64); const agentSecret = toSafeString(config?.wecomAgentSecret, 500); + const requireAgentId = config?.wecomRequireAgentId !== false; const missing = []; 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 (missing.length) { return buildMissingConfigResult(missing); diff --git a/server/src/services/integrations/wecomDirectory.js b/server/src/services/integrations/wecomDirectory.js new file mode 100644 index 00000000..7bc943d7 --- /dev/null +++ b/server/src/services/integrations/wecomDirectory.js @@ -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, +}; diff --git a/server/tests/unit/routes/employees.wecomDirectory.test.js b/server/tests/unit/routes/employees.wecomDirectory.test.js new file mode 100644 index 00000000..b9fcf453 --- /dev/null +++ b/server/tests/unit/routes/employees.wecomDirectory.test.js @@ -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: "hrwd@example.com", + 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(); + }); +});