merge: 收口用户匹配与花名册同步修复

This commit is contained in:
WuYongJie 2026-05-19 20:20:54 +08:00
commit 12d92a1b4b
7 changed files with 471 additions and 8 deletions

View File

@ -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` 不是当前项目生产部署目标。
### 当前主线入口

View File

@ -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、文档或长期记忆。

View File

@ -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<UserTreeItem | null>(null)
const [activationUser, setActivationUser] = useState<UserTreeItem | null>(null)
const [handoverUser, setHandoverUser] = useState<UserTreeItem | null>(null)
const [employeeMatchUser, setEmployeeMatchUser] = useState<UserTreeItem | null>(null)
const [employeeMatchLoading, setEmployeeMatchLoading] = useState(false)
const [employeeMatchError, setEmployeeMatchError] = useState<string | null>(null)
const [deleteUserId, setDeleteUserId] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(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<Role[]>(() => {
@ -467,6 +496,14 @@ function UsersPageContent() {
{handoverUser ? (
<HandoverModal isOpen={!!handoverUser} user={handoverUser} onClose={closeHandoverModal} onSuccess={handleHandoverSuccess} />
) : null}
<EmployeeMatchModal
isOpen={!!employeeMatchUser}
user={employeeMatchUser}
loading={employeeMatchLoading}
error={employeeMatchError}
onClose={closeEmployeeMatchModal}
onConfirm={confirmEmployeeMatch}
/>
{editingUser ? (
<EditUserModal
isOpen={!!editingUser}

View File

@ -0,0 +1,179 @@
'use client';
import { useState } from 'react';
import { AlertCircle, BadgeCheck, Link2 } from 'lucide-react';
import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
interface EmployeeMatchUser {
id: string;
name: string;
email: string;
employeeDirectoryId?: string | null;
employeeDirectory?: {
jobNumber: string | null;
name: string | null;
title: string | null;
} | null;
}
interface EmployeeMatchModalProps {
isOpen: boolean;
user: EmployeeMatchUser | null;
loading?: boolean;
error?: string | null;
onClose: () => void;
onConfirm: (jobNumber: string) => Promise<void> | 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 = (
<div className="flex gap-3">
<Button
type="button"
variant="gray"
size="regular"
onClick={onClose}
disabled={loading}
className="flex-1 rounded-md"
>
取消
</Button>
<Button
type="submit"
form="employee-match-form"
variant="filled"
size="regular"
loading={loading}
disabled={loading}
className="flex-1 rounded-md"
>
<Link2 className="h-4 w-4" />
{user?.employeeDirectoryId ? '重新匹配' : '确认匹配'}
</Button>
</div>
);
return (
<Modal
isOpen={isOpen}
onClose={loading ? () => {} : onClose}
title={title}
size="small"
showCloseButton={!loading}
closeOnOverlayClick={!loading}
footer={footer}
>
<EmployeeMatchForm
key={formKey}
user={user}
loading={loading}
error={error}
matchedSummary={matchedSummary}
onConfirm={onConfirm}
/>
</Modal>
);
}
function EmployeeMatchForm({
user,
loading,
error,
matchedSummary,
onConfirm,
}: {
user: EmployeeMatchUser | null;
loading: boolean;
error?: string | null;
matchedSummary: string | null;
onConfirm: (jobNumber: string) => Promise<void> | void;
}) {
const [jobNumber, setJobNumber] = useState(user?.employeeDirectory?.jobNumber ?? '');
const [fieldError, setFieldError] = useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedJobNumber = jobNumber.trim();
if (!normalizedJobNumber) {
setFieldError('请输入员工工号');
return;
}
setFieldError(null);
await onConfirm(normalizedJobNumber);
};
return (
<form id="employee-match-form" onSubmit={handleSubmit} className="space-y-5 p-1">
<section className="rounded-md border border-[var(--obs-shell-border)] bg-[var(--obs-shell-content-bg)] p-4">
<div className="flex items-center gap-2">
<BadgeCheck className="h-4 w-4 text-[var(--obs-shell-control-text)]" />
<h3 className="text-[12px] font-black text-[var(--obs-shell-title)]">账号与员工库绑定</h3>
</div>
<p className="mt-3 text-[12px] font-medium leading-5 text-[var(--obs-shell-muted)]">
将系统账号与 HR 员工库工号绑定后,组织、权限和钉钉资料会以员工库为核对依据。
</p>
{user ? (
<div className="mt-3 rounded-[5px] bg-white px-3 py-2 text-[12px] font-semibold text-[var(--obs-shell-title)]">
<span>{user.name}</span>
<span className="mx-2 text-[var(--obs-shell-subtle)]">/</span>
<span className="text-[var(--obs-shell-muted)]">{user.email}</span>
</div>
) : null}
{matchedSummary ? (
<p className="mt-2 text-[11px] font-semibold text-[var(--obs-shell-success)]">
当前匹配:{matchedSummary}
</p>
) : null}
</section>
<Input
label="员工工号"
value={jobNumber}
onChange={(event) => {
setJobNumber(event.target.value);
setFieldError(null);
}}
placeholder="请输入员工库工号"
required
autoComplete="off"
disabled={loading}
errorText={fieldError ?? undefined}
containerClassName={formFieldClass}
className={formControlClass}
/>
{error ? (
<div className="flex items-start gap-3 rounded-md border border-[var(--obs-shell-danger-border)] bg-[var(--obs-shell-danger-bg)] px-4 py-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-[var(--obs-shell-danger)]" />
<p className="text-[12px] font-semibold leading-5 text-[var(--obs-shell-danger)]">{error}</p>
</div>
) : null}
</form>
);
}

View File

@ -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(
<EmployeeMatchModal
isOpen
user={baseUser}
onClose={jest.fn()}
onConfirm={handleConfirm}
/>,
);
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(
<EmployeeMatchModal
isOpen
user={{
...baseUser,
employeeDirectoryId: 'employee-1',
employeeDirectory: {
jobNumber: 'A1024',
name: '吴用杰',
title: '设计主管',
},
}}
onClose={jest.fn()}
onConfirm={jest.fn()}
/>,
);
expect(await screen.findByRole('dialog', { name: '重新匹配员工库' })).toBeInTheDocument();
expect(screen.getByLabelText(/员工工号/i)).toHaveValue('A1024');
expect(screen.getByText('当前匹配:A1024 / 吴用杰 / 设计主管')).toBeInTheDocument();
});
});

View File

@ -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',
}),
}),
)
})
})

View File

@ -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<string>,
field: EmployeeIdentityField,
value: string | null | undefined
) {
const key = buildEmployeeIdentityKey(field, value)
if (key) {
keys.add(key)
}
}
class EmployeeDirectoryRepository {
private async resolveUniqueDepartmentName(
db: Pick<Prisma.TransactionClient, 'department'>,
@ -217,6 +240,14 @@ class EmployeeDirectoryRepository {
}) {
const now = new Date()
const departmentMap = new Map<string, { id: string; fullPath: string }>()
const activeEmployeeIdentityKeys = new Set<string>()
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,