From 60fdf33bada5e4814b32a49dc820c3595e1480b2 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 19 May 2026 20:18:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(users):=20=E4=BF=AE=E5=A4=8D=E5=91=98?= =?UTF-8?q?=E5=B7=A5=E5=BA=93=E5=8C=B9=E9=85=8D=E4=B8=8E=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/workflow/团队协作模式.md | 1 + .../docs/workflow/生产部署入口说明.md | 37 ++++ furniture-3d-system/src/app/users/page.tsx | 53 +++++- .../components/users/EmployeeMatchModal.tsx | 179 ++++++++++++++++++ .../__tests__/EmployeeMatchModal.test.tsx | 60 ++++++ .../employee-directory.repository.test.ts | 107 +++++++++++ .../employee-directory.repository.ts | 42 ++++ 7 files changed, 471 insertions(+), 8 deletions(-) create mode 100644 furniture-3d-system/docs/workflow/生产部署入口说明.md create mode 100644 furniture-3d-system/src/components/users/EmployeeMatchModal.tsx create mode 100644 furniture-3d-system/src/components/users/__tests__/EmployeeMatchModal.test.tsx create mode 100644 furniture-3d-system/src/server/repositories/__tests__/employee-directory.repository.test.ts diff --git a/furniture-3d-system/docs/workflow/团队协作模式.md b/furniture-3d-system/docs/workflow/团队协作模式.md index 57021086..d1d12915 100644 --- a/furniture-3d-system/docs/workflow/团队协作模式.md +++ b/furniture-3d-system/docs/workflow/团队协作模式.md @@ -31,6 +31,7 @@ - `main` 是最终发布主线,由 `WuYongJie` 收口;日常开发默认同步 `origin/codex/xuxiake-mainline`。 - 这份文档现在只固定 Git 协作方式、主线收口规则和共享接缝处理原则,不再把当前模块职责长期写死在这里。 +- 生产部署入口以 `docs/workflow/生产部署入口说明.md` 和 `scripts/deploy-yydd-prod-from-git.ps1` 为准;`192.168.58.20` 不是当前项目生产部署目标。 ### 当前主线入口 diff --git a/furniture-3d-system/docs/workflow/生产部署入口说明.md b/furniture-3d-system/docs/workflow/生产部署入口说明.md new file mode 100644 index 00000000..2ea17fe6 --- /dev/null +++ b/furniture-3d-system/docs/workflow/生产部署入口说明.md @@ -0,0 +1,37 @@ +# 生产部署入口说明 + +适用范围:`furniture-3d-system` 当前生产部署入口核对。 + +这份文档只记录非敏感入口与核查口径,不保存密码、密钥、数据库连接串或对象存储密钥。 + +## 权威生产目标 + +- SSH 入口:`root@ltai5tdb2ogyj59fquyedh.yydd.ai` +- SSH 端口:`60022` +- 生产部署目录:`/opt/furniture-3d-system-prod` +- PM2 进程名:`furniture-3d-system-prod` +- 应用本机端口:`3001` +- 默认部署来源:`origin/main` + +## 本仓库部署脚本 + +- `scripts/deploy-yydd-prod-from-git.ps1` +- `scripts/create-yydd-prod-deploy.ps1` + +执行部署或生产核查前,优先读取上面两个脚本的默认参数,并在远端实时确认当前 PM2 进程、`current` 软链接和健康检查结果。 + +## 明确排除 + +- `192.168.58.20` 不是当前 `furniture-3d-system` 的生产部署目标。 +- 本机泛别名 `prod` 不能作为当前项目生产服务器判断依据。 +- 旧 `gongxiangp2 / youyidian-3d-material` 的生产或 FreeNAS 信息不能直接套用到当前项目。 + +## 最小核查口径 + +生产相关动作开始前,至少确认: + +1. 目标主机是 `ltai5tdb2ogyj59fquyedh.yydd.ai:60022`。 +2. 远端存在 `/opt/furniture-3d-system-prod`。 +3. PM2 中存在 `furniture-3d-system-prod`。 +4. 应用监听端口是 `3001`。 +5. 不把密码、密钥或连接串写入 Git、文档或长期记忆。 diff --git a/furniture-3d-system/src/app/users/page.tsx b/furniture-3d-system/src/app/users/page.tsx index aad5368e..e56a64ba 100644 --- a/furniture-3d-system/src/app/users/page.tsx +++ b/furniture-3d-system/src/app/users/page.tsx @@ -25,6 +25,7 @@ import { DeleteConfirmDialog } from '@/components/ui/DeleteConfirmDialog' import { ActivateUserModal } from '@/components/users/ActivateUserModal' import { CreateUserModal } from '@/components/users/CreateUserModal' import { EditUserModal } from '@/components/users/EditUserModal' +import { EmployeeMatchModal } from '@/components/users/EmployeeMatchModal' import { HandoverModal } from '@/components/users/HandoverModal' import { OrganizationManageModal } from '@/components/users/OrganizationManageModal' import { DepartmentAccessTree, type UserTreeItem } from '@/components/users/DepartmentAccessTree' @@ -38,6 +39,7 @@ import { import { canAssignRoleByHierarchy, getAssignableRolesForRole } from '@/lib/role-scope' import { BUSINESS_ACCOUNT_TYPE_LABELS, BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types' import { useDebounce } from '@/hooks/usePerformance' +import { getApiErrorMessage } from '@/lib/utils' const ROLE_OPTIONS = [ { value: '', label: '所有账号权限', icon: Users }, @@ -96,6 +98,9 @@ function UsersPageContent() { const [editingUser, setEditingUser] = useState(null) const [activationUser, setActivationUser] = useState(null) const [handoverUser, setHandoverUser] = useState(null) + const [employeeMatchUser, setEmployeeMatchUser] = useState(null) + const [employeeMatchLoading, setEmployeeMatchLoading] = useState(false) + const [employeeMatchError, setEmployeeMatchError] = useState(null) const [deleteUserId, setDeleteUserId] = useState(null) const [actionError, setActionError] = useState(null) @@ -156,6 +161,11 @@ function UsersPageContent() { const closeEditModal = useCallback(() => setEditingUser(null), []) const closeActivateModal = useCallback(() => setActivationUser(null), []) const closeHandoverModal = useCallback(() => setHandoverUser(null), []) + const closeEmployeeMatchModal = useCallback(() => { + if (employeeMatchLoading) return + setEmployeeMatchUser(null) + setEmployeeMatchError(null) + }, [employeeMatchLoading]) const closeDeleteDialog = useCallback(() => setDeleteUserId(null), []) const handleCreateSuccess = useCallback(() => { @@ -245,28 +255,47 @@ function UsersPageContent() { ) const handleEmployeeMatch = useCallback( - async (user: UserTreeItem) => { - const jobNumber = window.prompt(`请输入 ${user.name} 的工号`) - if (!jobNumber?.trim()) return + (user: UserTreeItem) => { + setActionError(null) + setEmployeeMatchError(null) + setSelectedUser(user) + setEmployeeMatchUser(user) + }, + [], + ) + + const confirmEmployeeMatch = useCallback( + async (jobNumber: string) => { + if (!employeeMatchUser) return try { + setEmployeeMatchLoading(true) + setEmployeeMatchError(null) setActionError(null) - const response = await fetch(`/api/users/${user.id}/employee-match`, { + const response = await fetch(`/api/users/${employeeMatchUser.id}/employee-match`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ jobNumber: jobNumber.trim() }), + body: JSON.stringify({ jobNumber }), }) const payload = await response.json().catch(() => ({})) if (!response.ok) { - throw new Error(payload.error?.message || '员工库匹配失败') + throw new Error(getApiErrorMessage(payload.error, '员工库匹配失败')) } + + const matchedUserId = employeeMatchUser.id + setEmployeeMatchUser(null) triggerRefresh() + if (selectedUser?.id === matchedUserId) { + await refreshSelectedUserDetail(matchedUserId) + } } catch (error) { - setActionError(error instanceof Error ? error.message : '员工库匹配失败') + setEmployeeMatchError(error instanceof Error ? error.message : '员工库匹配失败') + } finally { + setEmployeeMatchLoading(false) } }, - [triggerRefresh], + [employeeMatchUser, refreshSelectedUserDetail, selectedUser?.id, triggerRefresh], ) const selectedUserAllowedRoles = useMemo(() => { @@ -467,6 +496,14 @@ function UsersPageContent() { {handoverUser ? ( ) : null} + {editingUser ? ( void; + onConfirm: (jobNumber: string) => Promise | void; +} + +const formFieldClass = + 'space-y-1.5 [&>label]:mb-0 [&>label]:text-[12px] [&>label]:font-semibold [&>label]:leading-5 [&>label]:tracking-normal [&>label]:text-[var(--obs-shell-muted)]'; + +const formControlClass = + 'h-10 rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-3 py-0 text-[13px] font-semibold leading-5 tracking-normal text-[var(--obs-shell-title)] placeholder:text-[var(--obs-shell-subtle)] focus:border focus:border-[var(--obs-shell-title)] focus:px-3 focus:py-0'; + +export function EmployeeMatchModal({ + isOpen, + user, + loading = false, + error, + onClose, + onConfirm, +}: EmployeeMatchModalProps) { + const title = user?.employeeDirectoryId ? '重新匹配员工库' : '匹配员工库'; + const employeeDirectory = user?.employeeDirectory; + const matchedSummaryParts = [ + employeeDirectory?.jobNumber, + employeeDirectory?.name, + employeeDirectory?.title, + ].filter(Boolean); + const matchedSummary = matchedSummaryParts.length > 0 ? matchedSummaryParts.join(' / ') : null; + const formKey = `${isOpen ? 'open' : 'closed'}-${user?.id ?? 'none'}-${employeeDirectory?.jobNumber ?? ''}`; + + const footer = ( +
+ + +
+ ); + + return ( + {} : onClose} + title={title} + size="small" + showCloseButton={!loading} + closeOnOverlayClick={!loading} + footer={footer} + > + + + ); +} + +function EmployeeMatchForm({ + user, + loading, + error, + matchedSummary, + onConfirm, +}: { + user: EmployeeMatchUser | null; + loading: boolean; + error?: string | null; + matchedSummary: string | null; + onConfirm: (jobNumber: string) => Promise | void; +}) { + const [jobNumber, setJobNumber] = useState(user?.employeeDirectory?.jobNumber ?? ''); + const [fieldError, setFieldError] = useState(null); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const normalizedJobNumber = jobNumber.trim(); + if (!normalizedJobNumber) { + setFieldError('请输入员工工号'); + return; + } + + setFieldError(null); + await onConfirm(normalizedJobNumber); + }; + + return ( +
+
+
+ +

账号与员工库绑定

+
+

+ 将系统账号与 HR 员工库工号绑定后,组织、权限和钉钉资料会以员工库为核对依据。 +

+ {user ? ( +
+ {user.name} + / + {user.email} +
+ ) : null} + {matchedSummary ? ( +

+ 当前匹配:{matchedSummary} +

+ ) : null} +
+ + { + setJobNumber(event.target.value); + setFieldError(null); + }} + placeholder="请输入员工库工号" + required + autoComplete="off" + disabled={loading} + errorText={fieldError ?? undefined} + containerClassName={formFieldClass} + className={formControlClass} + /> + + {error ? ( +
+ +

{error}

+
+ ) : null} +
+ ); +} diff --git a/furniture-3d-system/src/components/users/__tests__/EmployeeMatchModal.test.tsx b/furniture-3d-system/src/components/users/__tests__/EmployeeMatchModal.test.tsx new file mode 100644 index 00000000..f824a862 --- /dev/null +++ b/furniture-3d-system/src/components/users/__tests__/EmployeeMatchModal.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { EmployeeMatchModal } from '../EmployeeMatchModal'; + +const baseUser = { + id: 'user-1', + name: '吴用杰', + email: 'wuyongjie@example.com', + employeeDirectoryId: null, + employeeDirectory: null, +}; + +describe('EmployeeMatchModal', () => { + it('使用统一 White Obsidian 弹窗外壳并提交工号', async () => { + const actor = userEvent.setup(); + const handleConfirm = jest.fn(); + + render( + , + ); + + expect(await screen.findByRole('dialog', { name: '匹配员工库' })).toBeInTheDocument(); + expect(screen.getByTestId('obsidian-modal-panel')).toBeInTheDocument(); + + const jobNumberInput = screen.getByLabelText(/员工工号/i); + fireEvent.change(jobNumberInput, { target: { value: 'N05' } }); + await waitFor(() => expect(jobNumberInput).toHaveValue('N05')); + await actor.click(screen.getByRole('button', { name: /确认匹配/i })); + + expect(handleConfirm).toHaveBeenCalledWith('N05'); + }); + + it('重匹配时带出当前员工库工号', async () => { + render( + , + ); + + expect(await screen.findByRole('dialog', { name: '重新匹配员工库' })).toBeInTheDocument(); + expect(screen.getByLabelText(/员工工号/i)).toHaveValue('A1024'); + expect(screen.getByText('当前匹配:A1024 / 吴用杰 / 设计主管')).toBeInTheDocument(); + }); +}); diff --git a/furniture-3d-system/src/server/repositories/__tests__/employee-directory.repository.test.ts b/furniture-3d-system/src/server/repositories/__tests__/employee-directory.repository.test.ts new file mode 100644 index 00000000..d9bec41c --- /dev/null +++ b/furniture-3d-system/src/server/repositories/__tests__/employee-directory.repository.test.ts @@ -0,0 +1,107 @@ +jest.mock('@/lib/prisma', () => ({ + prisma: { + department: { + findFirst: jest.fn(), + upsert: jest.fn(), + }, + employeeDirectory: { + findFirst: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }, + }, +})) + +import { prisma } from '@/lib/prisma' +import { employeeDirectoryRepository } from '../employee-directory.repository' + +const mockPrisma = prisma as unknown as { + department: { + findFirst: jest.Mock + upsert: jest.Mock + } + employeeDirectory: { + findFirst: jest.Mock + update: jest.Mock + create: jest.Mock + } +} + +describe('employeeDirectoryRepository.upsertMany', () => { + beforeEach(() => { + jest.clearAllMocks() + mockPrisma.employeeDirectory.findFirst.mockResolvedValue(null) + mockPrisma.employeeDirectory.update.mockResolvedValue({}) + mockPrisma.employeeDirectory.create.mockResolvedValue({}) + }) + + it('同一员工同时出现在在职和离职数据时,以在职数据为准', async () => { + await employeeDirectoryRepository.upsertMany({ + departments: [], + employees: [ + { + name: '希文', + jobNumber: 'JS26030202', + dingtalkUserId: '17724155651225136', + title: '空间设计师', + }, + ], + dimissionEntries: [ + { + jobNumber: 'js26030202', + rawSnapshot: { + status: 2, + pre_status: 2, + }, + }, + ], + }) + + expect(mockPrisma.employeeDirectory.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: '希文', + jobNumber: 'JS26030202', + sourceStatus: 'EMPLOYED', + }), + }), + ) + expect(mockPrisma.employeeDirectory.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + sourceStatus: 'DIMISSIONED', + }), + }), + ) + }) + + it('只存在离职数据时仍会标记匹配员工为离职', async () => { + mockPrisma.employeeDirectory.findFirst.mockResolvedValueOnce({ + id: 'employee-1', + user: null, + }) + + await employeeDirectoryRepository.upsertMany({ + departments: [], + employees: [], + dimissionEntries: [ + { + jobNumber: 'JS26030202', + rawSnapshot: { + status: 2, + pre_status: 2, + }, + }, + ], + }) + + expect(mockPrisma.employeeDirectory.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'employee-1' }, + data: expect.objectContaining({ + sourceStatus: 'DIMISSIONED', + }), + }), + ) + }) +}) diff --git a/furniture-3d-system/src/server/repositories/employee-directory.repository.ts b/furniture-3d-system/src/server/repositories/employee-directory.repository.ts index 16cd281b..9e159d17 100644 --- a/furniture-3d-system/src/server/repositories/employee-directory.repository.ts +++ b/furniture-3d-system/src/server/repositories/employee-directory.repository.ts @@ -36,6 +36,29 @@ export type EmployeeDirectoryWithUser = Prisma.EmployeeDirectoryGetPayload<{ } }> +type EmployeeIdentityField = 'dingtalkUserId' | 'dingtalkUnionId' | 'mobile' | 'jobNumber' + +function normalizeEmployeeIdentity(value: string | null | undefined): string | null { + const text = String(value || '').trim() + return text ? text.toLowerCase() : null +} + +function buildEmployeeIdentityKey(field: EmployeeIdentityField, value: string | null | undefined): string | null { + const normalized = normalizeEmployeeIdentity(value) + return normalized ? `${field}:${normalized}` : null +} + +function addEmployeeIdentityKey( + keys: Set, + field: EmployeeIdentityField, + value: string | null | undefined +) { + const key = buildEmployeeIdentityKey(field, value) + if (key) { + keys.add(key) + } +} + class EmployeeDirectoryRepository { private async resolveUniqueDepartmentName( db: Pick, @@ -217,6 +240,14 @@ class EmployeeDirectoryRepository { }) { const now = new Date() const departmentMap = new Map() + const activeEmployeeIdentityKeys = new Set() + + for (const employee of input.employees) { + addEmployeeIdentityKey(activeEmployeeIdentityKeys, 'dingtalkUserId', employee.dingtalkUserId) + addEmployeeIdentityKey(activeEmployeeIdentityKeys, 'dingtalkUnionId', employee.dingtalkUnionId) + addEmployeeIdentityKey(activeEmployeeIdentityKeys, 'mobile', employee.mobile) + addEmployeeIdentityKey(activeEmployeeIdentityKeys, 'jobNumber', employee.jobNumber) + } const sortedDepartments = [...input.departments].sort((a, b) => { const aDepth = a.parentExternalId ? 1 : 0 @@ -337,6 +368,17 @@ class EmployeeDirectoryRepository { } for (const dimission of input.dimissionEntries) { + const activeIdentityMatched = [ + buildEmployeeIdentityKey('dingtalkUserId', dimission.dingtalkUserId), + buildEmployeeIdentityKey('dingtalkUnionId', dimission.dingtalkUnionId), + buildEmployeeIdentityKey('mobile', dimission.mobile), + buildEmployeeIdentityKey('jobNumber', dimission.jobNumber), + ].some((key) => key !== null && activeEmployeeIdentityKeys.has(key)) + + if (activeIdentityMatched) { + continue + } + const dimissionConditions = [ dimission.dingtalkUserId ? { dingtalkUserId: dimission.dingtalkUserId } : undefined, dimission.dingtalkUnionId ? { dingtalkUnionId: dimission.dingtalkUnionId } : undefined,