fix: 完善考核调研人员多部门归属

This commit is contained in:
chenpei 2026-07-27 13:31:36 +08:00
parent 063bbca0ef
commit c35ab8c663
10 changed files with 1243 additions and 96 deletions

View File

@ -108,6 +108,92 @@ describe('PerformanceSurveyAdminPeople', () => {
expect(onSuccess).toHaveBeenCalledWith('已为 2 人分配岗位')
})
test('清楚区分主部门、兼任部门、职级和钉钉身份', async () => {
const request = jest.fn(async () => ({
items: [{
id: 'employee-1',
employeeId: 'employee-1',
displayName: '忆江',
departmentName: '综合支持部',
departmentPath: '集团/品牌中心/综合支持部',
departmentMemberships: [{
id: 'membership-primary',
sourceDeptId: 'dept-primary',
departmentName: '综合支持部',
departmentPath: '集团/品牌中心/综合支持部',
isPrimary: true,
syncedAt: '2026-07-27T01:00:00.000Z',
}, {
id: 'membership-photo',
sourceDeptId: 'dept-photo',
departmentName: '摄影中心',
departmentPath: '集团/摄影中心',
isPrimary: false,
syncedAt: '2026-07-27T01:00:00.000Z',
}, {
id: 'membership-support',
sourceDeptId: 'dept-support',
departmentName: '综合支持二部',
departmentPath: '集团/综合支持二部',
isPrimary: false,
syncedAt: '2026-07-27T01:00:00.000Z',
}, {
id: 'membership-ai',
sourceDeptId: 'dept-ai',
departmentName: 'AI 中心',
departmentPath: '集团/AI 中心',
isPrimary: false,
syncedAt: '2026-07-27T01:00:00.000Z',
}],
positionTitle: '视觉设计师',
positionLevel: '4',
levelType: 'P',
isLeadership: false,
isDingtalkAdmin: true,
isDingtalkBoss: false,
isDingtalkSenior: true,
participation: 'assigned',
positionGroupName: '品牌设计师',
}, {
id: 'employee-2',
employeeId: 'employee-2',
displayName: '元泽',
departmentName: '品牌中心',
positionTitle: '负责人',
positionLevel: '1',
levelType: 'M',
isLeadership: true,
isDingtalkAdmin: true,
isDingtalkBoss: true,
participation: 'assigned',
positionGroupName: '品牌管理',
}],
total: 2,
})) as unknown as jest.MockedFunction<PerformanceSurveyAdminRequest>
renderPeople(request)
const desktop = screen.getByTestId('people-desktop-table')
await waitFor(() => expect(within(desktop).getByText('忆江')).toBeInTheDocument())
expect(screen.getByPlaceholderText('姓名、部门、职位或职级')).toBeInTheDocument()
expect(within(desktop).getByText('集团/品牌中心/综合支持部')).toBeInTheDocument()
expect(within(desktop).getByText('摄影中心、综合支持二部 等 3 个部门')).toBeInTheDocument()
expect(within(desktop).getByTitle('摄影中心、综合支持二部、AI 中心')).toBeInTheDocument()
expect(within(desktop).getByText('P4')).toBeInTheDocument()
expect(within(desktop).getByText('M1')).toBeInTheDocument()
expect(within(desktop).getByText('管理岗')).toBeInTheDocument()
expect(within(desktop).getAllByText('钉钉管理员')).toHaveLength(2)
expect(within(desktop).getByText('钉钉企业负责人')).toBeInTheDocument()
expect(within(desktop).getByText('钉钉高管')).toBeInTheDocument()
const mobile = screen.getByTestId('people-mobile-cards')
expect(within(mobile).getAllByText('主部门')).toHaveLength(2)
expect(within(mobile).getByText('摄影中心、综合支持二部 等 3 个部门')).toBeInTheDocument()
expect(within(mobile).getByText('P4')).toBeInTheDocument()
expect(within(mobile).getAllByText('钉钉管理员')).toHaveLength(2)
expect(screen.queryByText('系统管理员')).not.toBeInTheDocument()
})
test('网络失败显示中文可恢复提示', async () => {
const request = jest.fn()
.mockRejectedValueOnce(new Error('Failed to fetch'))

View File

@ -17,7 +17,21 @@ type AssignmentRow = {
displayName: string
departmentName?: string | null
departmentPath?: string | null
departmentMemberships?: Array<{
id: string
sourceDeptId?: string | null
departmentName?: string | null
departmentPath?: string | null
isPrimary?: boolean
syncedAt?: string | null
}>
positionTitle?: string | null
positionLevel?: string | null
levelType?: string | null
isLeadership?: boolean
isDingtalkAdmin?: boolean
isDingtalkBoss?: boolean
isDingtalkSenior?: boolean
positionGroupId?: string | null
positionGroupName?: string | null
manualAssignmentId?: string | null
@ -100,6 +114,108 @@ function participationLabel(row: AssignmentRow) {
return '待分配'
}
function readPositionLevel(row: AssignmentRow) {
const level = row.positionLevel?.trim()
const type = row.levelType?.trim().toUpperCase()
if (!level) return type || null
if (/^[MP]\d+/i.test(level)) return level.toUpperCase()
if (type && /^\d/.test(level)) return `${type}${level}`
return level
}
function readDepartmentSummary(row: AssignmentRow) {
const memberships = (row.departmentMemberships || [])
.filter((membership) => membership.departmentName?.trim() || membership.departmentPath?.trim())
const legacyPrimary = row.departmentPath?.trim() || row.departmentName?.trim() || null
const explicitPrimary = memberships.find((membership) => membership.isPrimary)
const matchingPrimary = memberships.find((membership) => (
membership.departmentPath?.trim() === row.departmentPath?.trim()
|| membership.departmentName?.trim() === row.departmentName?.trim()
))
const primary = explicitPrimary || matchingPrimary || (legacyPrimary ? null : memberships[0])
const primaryLabel = primary?.departmentPath?.trim() || primary?.departmentName?.trim() || legacyPrimary || '主部门未填写'
const primaryId = primary?.id
const secondaryLabels = Array.from(new Set(
memberships
.filter((membership) => membership.id !== primaryId && !membership.isPrimary)
.map((membership) => membership.departmentName?.trim() || membership.departmentPath?.trim() || '')
.filter((label) => label && label !== primaryLabel && label !== row.departmentName?.trim()),
))
return { primaryLabel, secondaryLabels }
}
function summarizeDepartments(departments: string[]) {
if (departments.length <= 2) return departments.join('、')
return `${departments.slice(0, 2).join('、')} 等 ${departments.length} 个部门`
}
function DingtalkIdentityBadges({ row }: { row: AssignmentRow }) {
const labels: string[] = []
if (row.isDingtalkAdmin) labels.push('钉钉管理员')
if (row.isDingtalkBoss) labels.push('钉钉企业负责人')
if (row.isDingtalkSenior) labels.push('钉钉高管')
if (labels.length === 0) return null
return (
<span className="mt-1 flex flex-wrap gap-1">
{labels.map((label) => (
<span key={label} className="inline-flex rounded bg-[#f4f3f3] px-1.5 py-0.5 text-[11px] font-semibold text-[#5f5e5e]">
{label}
</span>
))}
</span>
)
}
function DepartmentSummary({ row, compact = false }: { row: AssignmentRow; compact?: boolean }) {
const { primaryLabel, secondaryLabels } = readDepartmentSummary(row)
const secondarySummary = summarizeDepartments(secondaryLabels)
const allSecondaryDepartments = secondaryLabels.join('、')
return (
<span className="block min-w-0">
<span className="flex min-w-0 items-start gap-1.5">
<span className="mt-0.5 shrink-0 rounded bg-[#eef5ff] px-1.5 py-0.5 text-[10px] font-bold text-[#0050cb]">主部门</span>
<span className={`${compact ? 'line-clamp-2' : 'break-words'} min-w-0 text-[12px] leading-5 text-[#3f4247]`} title={primaryLabel}>
{primaryLabel}
</span>
</span>
{secondaryLabels.length > 0 && (
<span className="mt-1 flex min-w-0 items-start gap-1.5">
<span className="mt-0.5 shrink-0 rounded bg-[#f4f3f3] px-1.5 py-0.5 text-[10px] font-bold text-[#5f5e5e]">兼任</span>
<span className="min-w-0 text-[12px] leading-5 text-[#5f5e5e]" title={allSecondaryDepartments}>
{secondarySummary}
</span>
</span>
)}
</span>
)
}
function PositionSummary({ row }: { row: AssignmentRow }) {
const level = readPositionLevel(row)
return (
<span className="block min-w-0">
<span className="break-words text-[12px] leading-5 text-[#3f4247]">{row.positionTitle || '职位未填写'}</span>
{(level || row.isLeadership) && (
<span className="mt-1 flex flex-wrap gap-1">
{level && (
<span className="inline-flex rounded bg-[#eef5ff] px-1.5 py-0.5 text-[11px] font-bold text-[#0050cb]">
{level}
</span>
)}
{row.isLeadership && (
<span className="inline-flex rounded bg-[#fff4df] px-1.5 py-0.5 text-[11px] font-semibold text-[#8a5700]">
管理岗
</span>
)}
</span>
)}
</span>
)
}
function readTechnicalError(error: unknown) {
if (!(error instanceof Error)) return null
const detail = error.message.trim()
@ -307,7 +423,7 @@ export function PerformanceSurveyAdminPeople({
onKeyDown={(event) => {
if (event.key === 'Enter') void load()
}}
placeholder="姓名、部门或职位"
placeholder="姓名、部门、职位或职级"
className="h-11 rounded border border-[#c2c6d8] px-3 text-[13px] text-[#1a1c1c] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dbe6ff]"
/>
</label>
@ -380,8 +496,8 @@ export function PerformanceSurveyAdminPeople({
</label>
</th>
<th scope="col" className="px-3 py-3">人员</th>
<th scope="col" className="px-3 py-3">部门</th>
<th scope="col" className="px-3 py-3">职位</th>
<th scope="col" className="min-w-[240px] px-3 py-3">部门归属</th>
<th scope="col" className="min-w-[140px] px-3 py-3">职位 / 职级</th>
<th scope="col" className="w-[180px] px-3 py-3">当前分配</th>
</tr>
</thead>
@ -404,9 +520,12 @@ export function PerformanceSurveyAdminPeople({
/>
</label>
</td>
<td className="px-3 py-3 text-[13px] font-bold text-[#1a1c1c]">{row.displayName}</td>
<td className="px-3 py-3 text-[13px] text-[#5f5e5e]">{row.departmentPath || row.departmentName || '-'}</td>
<td className="px-3 py-3 text-[13px] text-[#5f5e5e]">{row.positionTitle || '-'}</td>
<td className="px-3 py-3 text-[13px] font-bold text-[#1a1c1c]">
<span className="block">{row.displayName}</span>
<DingtalkIdentityBadges row={row} />
</td>
<td className="px-3 py-3"><DepartmentSummary row={row} /></td>
<td className="px-3 py-3"><PositionSummary row={row} /></td>
<td className="px-3 py-3">
<span className={`inline-flex rounded px-2 py-1 text-[12px] font-semibold ${
row.participation === 'excluded'
@ -455,7 +574,10 @@ export function PerformanceSurveyAdminPeople({
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-start justify-between gap-2">
<span className="text-[14px] font-bold text-[#1a1c1c]">{row.displayName}</span>
<span className="min-w-0 text-[14px] font-bold text-[#1a1c1c]">
<span className="block">{row.displayName}</span>
<DingtalkIdentityBadges row={row} />
</span>
<span className={`inline-flex rounded px-2 py-1 text-[12px] font-semibold ${
row.participation === 'excluded'
? 'bg-[#f4f3f3] text-[#5f5e5e]'
@ -466,10 +588,12 @@ export function PerformanceSurveyAdminPeople({
{participationLabel(row)}
</span>
</span>
<span className="mt-1 block break-words text-[12px] leading-5 text-[#5f5e5e]">
{row.departmentPath || row.departmentName || '部门未填写'}
<span aria-hidden="true"> · </span>
{row.positionTitle || '职位未填写'}
<span className="mt-2 grid gap-2">
<DepartmentSummary row={row} compact />
<span className="flex min-w-0 items-start gap-1.5">
<span className="mt-0.5 shrink-0 rounded bg-[#f4f3f3] px-1.5 py-0.5 text-[10px] font-bold text-[#5f5e5e]">职位</span>
<PositionSummary row={row} />
</span>
</span>
</span>
</label>

View File

@ -5,19 +5,25 @@ import mssql from 'mssql'
import {
comparePerformanceSurveyEmployeeSyncPreview,
previewPerformanceSurveyEmployeeSync,
syncPerformanceSurveyEmployeeByIdentity,
} from '@/lib/performance-survey-report-sync'
jest.mock('@/lib/prisma', () => ({
prisma: {
$transaction: jest.fn(),
user: {
findMany: jest.fn(),
count: jest.fn(),
},
performanceSurveyEmployeeProfile: {
findMany: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
performanceSurveyDepartmentProfile: {
upsert: jest.fn(),
},
},
}))
@ -181,6 +187,73 @@ describe('performance survey employee sync preview', () => {
]))
})
test('只读预检会把新增的兼任部门识别为业务更新', () => {
const result = comparePerformanceSurveyEmployeeSyncPreview({
identities: [{
userId: 'user-qingyun',
dingtalkUserId: '09053022115891',
email: 'qingyun@example.com',
username: '清云',
}],
rows: [{
dingtalkUserId: '09053022115891',
dingEmail: 'qingyun@example.com',
contactName: '清云',
registerMainDeptId: '994456259',
registerDeptIds: '1085358755|994456259',
groupDepartmentName: '营销策划中心',
groupDepartmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心',
resolvedDepartments: [
{
sourceDeptId: '994456259',
departmentName: '营销策划中心',
departmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心',
isPrimary: true,
},
{
sourceDeptId: '1085358755',
departmentName: '品牌创意B部',
departmentPath: '江苏简尚品牌管理有限公司/品牌中心/品牌创意B部',
isPrimary: false,
},
],
}],
existingProfiles: [{
id: 'profile-qingyun',
dingtalkUserId: '09053022115891',
dingEmail: 'qingyun@example.com',
displayName: '清云',
departmentName: '营销策划中心',
departmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心',
isLeadership: false,
isDingtalkAdmin: false,
isDingtalkBoss: false,
isDingtalkSenior: false,
sourcePayload: {
source: 'JSReportReplica.rpt',
materialUserId: 'user-qingyun',
materialUserEmail: 'qingyun@example.com',
},
departmentMemberships: [{
sourceDeptId: '994456259',
departmentName: '营销策划中心',
departmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心',
isPrimary: true,
}],
}],
})
expect(result).toMatchObject({
created: 0,
updated: 1,
unchanged: 0,
})
expect(result.changedFields).toContainEqual({
field: 'departmentMemberships',
count: 1,
})
})
test('未配置上游时只返回兜底预检,且不会写入本地数据库', async () => {
;(prisma.user.findMany as jest.Mock).mockResolvedValue([{
id: 'user-1',
@ -218,6 +291,7 @@ describe('performance survey employee sync preview', () => {
})
expect(prisma.performanceSurveyEmployeeProfile.create).not.toHaveBeenCalled()
expect(prisma.performanceSurveyEmployeeProfile.update).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('管理员主动预检可只读访问上游,仍不会写入本地数据库', async () => {
@ -306,5 +380,169 @@ describe('performance survey employee sync preview', () => {
expect(pool.close).toHaveBeenCalledTimes(1)
expect(prisma.performanceSurveyEmployeeProfile.create).not.toHaveBeenCalled()
expect(prisma.performanceSurveyEmployeeProfile.update).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('单人正式同步会保存全部所属部门和钉钉身份字段,且不映射为系统权限', async () => {
process.env.REPORT_DB_SERVER = 'report.example.test'
process.env.REPORT_DB_DATABASE = 'report'
process.env.REPORT_DB_USER = 'readonly'
process.env.REPORT_DB_PASSWORD = 'secret'
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(null)
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([])
const profileCreate = jest.fn().mockResolvedValue({ id: 'profile-qingyun' })
const membershipDeleteMany = jest.fn().mockResolvedValue({ count: 0 })
const membershipCreateMany = jest.fn().mockResolvedValue({ count: 2 })
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({
performanceSurveyEmployeeProfile: {
create: profileCreate,
update: jest.fn(),
},
performanceSurveyEmployeeDepartmentMembership: {
deleteMany: membershipDeleteMany,
createMany: membershipCreateMany,
},
}))
const pool = {
request: jest.fn(() => {
const inputs = new Map<string, unknown>()
const request: {
input: jest.Mock
query: jest.Mock
} = {
input: jest.fn(),
query: jest.fn(),
}
request.input.mockImplementation((name: string, ...args: unknown[]) => {
inputs.set(name, args[args.length - 1])
return request
})
request.query.mockImplementation(async (query: string) => {
if (query.includes('INFORMATION_SCHEMA.COLUMNS')) {
const table = inputs.get('table')
const columns = table === 'hr_employee'
? [
'userid',
'email',
'job_number',
'name',
'title',
'active',
'dept_id_list',
'admin',
'boss',
'senior',
]
: table === 'hr_employee_register'
? [
'userid',
'sys00-name',
'sys00-flowerName',
'sys00-jobNumber',
'sys00-position',
'sys01-positionLevel',
'sys00-deptIds',
'sys00-mainDeptId',
]
: table === 'hr_flowerName_book'
? ['jobNum', 'flowerName', 'deptId']
: []
return { recordset: columns.map((COLUMN_NAME) => ({ COLUMN_NAME })) }
}
if (query.includes(';WITH input_depts')) {
return {
recordset: [
{
dept_id: 994456259,
path: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心',
},
{
dept_id: 1085358755,
path: '江苏简尚品牌管理有限公司/品牌中心/品牌创意B部',
},
],
}
}
if (query.includes('[rpt].[hr_employee] e')) {
expect(query).toContain('AS "isDingtalkAdmin"')
expect(query).toContain('AS "isDingtalkBoss"')
expect(query).toContain('AS "isDingtalkSenior"')
return {
recordset: [{
dingtalkUserId: '09053022115891',
dingEmail: 'qingyun@example.com',
jobNumber: 'JS15030101',
contactName: '清云',
contactTitle: '营销策划主管',
employeeDeptIdList: '994456259,1085358755',
registerName: '清云',
flowerName: '清云',
registerJobNumber: 'JS15030101',
positionTitle: '营销策划主管',
positionLevel: 'M1',
registerDeptIds: '1085358755|994456259',
registerMainDeptId: '994456259',
flowerBookFlowerName: '清云',
flowerBookDeptId: '[994121400,994456259]',
isDingtalkAdmin: true,
isDingtalkBoss: false,
isDingtalkSenior: true,
}],
}
}
throw new Error(`未预期的查询:${query}`)
})
return request
}),
close: jest.fn(),
}
;(mssql.connect as jest.Mock).mockResolvedValue(pool)
await syncPerformanceSurveyEmployeeByIdentity({
userId: 'material-user-qingyun',
dingtalkUserId: '09053022115891',
email: 'qingyun@example.com',
username: '清云',
})
expect(profileCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
displayName: '清云',
departmentName: '营销策划中心',
departmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心',
positionLevel: 'M1',
levelType: 'M',
isLeadership: true,
isDingtalkAdmin: true,
isDingtalkBoss: false,
isDingtalkSenior: true,
}),
})
expect(profileCreate.mock.calls[0][0].data).not.toHaveProperty('departmentMemberships')
expect(membershipDeleteMany).toHaveBeenCalledWith({
where: { employeeProfileId: 'profile-qingyun' },
})
expect(membershipCreateMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({
employeeProfileId: 'profile-qingyun',
sourceDeptId: '994456259',
departmentName: '营销策划中心',
isPrimary: true,
}),
expect.objectContaining({
employeeProfileId: 'profile-qingyun',
sourceDeptId: '1085358755',
departmentName: '品牌创意B部',
isPrimary: false,
}),
]),
})
expect(membershipCreateMany.mock.calls[0][0].data).toHaveLength(2)
expect(pool.close).toHaveBeenCalledTimes(1)
})
})

View File

@ -82,6 +82,51 @@ describe('performance survey position configuration', () => {
}, [creative])).toMatchObject({ status: 'unmatched', group: null })
})
test('兼任部门参与岗位匹配,跨部门命中不同岗位时明确返回冲突', () => {
const primaryGroup = group({
id: 'primary',
code: 'primary',
name: '营销策划',
rules: [{ departmentPathPrefix: '集团/营销策划中心', positionTitle: '营销策划主管' }],
})
const secondaryGroup = group({
id: 'secondary',
code: 'secondary',
name: '品牌创意B部',
rules: [{ departmentPathPrefix: '集团/品牌中心/品牌创意B部', positionTitle: '营销策划主管' }],
})
const employee = {
departmentPath: '集团/营销策划中心',
positionTitle: '营销策划主管',
departmentMemberships: [
{
departmentName: '营销策划中心',
departmentPath: '集团/营销策划中心',
isPrimary: true,
},
{
departmentName: '品牌创意B部',
departmentPath: '集团/品牌中心/品牌创意B部',
isPrimary: false,
},
],
}
expect(matchesPerformanceSurveyPositionRule(employee, secondaryGroup.rules[0])).toBe(true)
expect(resolvePerformanceSurveyPositionMembership(employee, [secondaryGroup])).toMatchObject({
status: 'matched',
group: { code: 'secondary' },
})
expect(resolvePerformanceSurveyPositionMembership(employee, [primaryGroup, secondaryGroup])).toMatchObject({
status: 'conflict',
group: null,
matches: [
{ code: 'primary' },
{ code: 'secondary' },
],
})
})
test('人工岗位或排除配置优先于自动岗位规则', () => {
const automatic = group({
id: 'automatic',

View File

@ -51,6 +51,9 @@ jest.mock('@/lib/prisma', () => ({
findMany: jest.fn(),
count: jest.fn(),
},
performanceSurveyEmployeeDepartmentMembership: {
findMany: jest.fn(),
},
$transaction: jest.fn(),
},
}))
@ -228,6 +231,8 @@ describe('performance survey service policies', () => {
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockReset()
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([])
;(prisma.performanceSurveyEmployeeProfile.count as jest.Mock).mockResolvedValue(0)
;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockReset()
;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockResolvedValue([])
;(prisma.performanceSurveyTarget.findMany as jest.Mock).mockResolvedValue([])
;(prisma.user.findMany as jest.Mock).mockReset()
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
@ -514,6 +519,16 @@ describe('performance survey service policies', () => {
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
{
departmentMemberships: {
some: {
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
],
},
},
},
],
},
{ id: 'employee-public' },
@ -551,6 +566,16 @@ describe('performance survey service policies', () => {
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
{
departmentMemberships: {
some: {
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
],
},
},
},
],
})
expect(JSON.stringify(scopeWhere)).not.toContain('departmentName')
@ -669,6 +694,16 @@ describe('performance survey service policies', () => {
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
{
departmentMemberships: {
some: {
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
],
},
},
},
],
},
},
@ -916,6 +951,71 @@ describe('performance survey service policies', () => {
expect(query.where.AND).toContainEqual({ id: { not: 'employee-1' } })
})
test('人员搜索会命中兼任部门并返回全部部门及钉钉身份', async () => {
const employee = {
...baseEmployee,
displayName: '清云',
positionTitle: '营销策划主管',
positionLevel: 'M1',
levelType: 'M',
isLeadership: true,
isDingtalkAdmin: true,
isDingtalkBoss: false,
isDingtalkSenior: false,
departmentMemberships: [
{
id: 'membership-primary',
sourceDeptId: '994456259',
departmentName: '营销策划中心',
departmentPath: '集团/龙晨品牌中心/营销策划中心',
isPrimary: true,
syncedAt: new Date('2026-07-27T00:00:00.000Z'),
},
{
id: 'membership-secondary',
sourceDeptId: '1085358755',
departmentName: '品牌创意B部',
departmentPath: '集团/品牌中心/品牌创意B部',
isPrimary: false,
syncedAt: new Date('2026-07-27T00:00:00.000Z'),
},
],
}
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValueOnce([employee])
const result = await searchPerformanceSurveyEmployees(session, new URLSearchParams({
search: '品牌创意B部',
purpose: 'evaluator',
}))
const query = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[0][0]
expect(query.where.AND).toContainEqual({
OR: expect.arrayContaining([
{
departmentMemberships: {
some: {
OR: [
{ departmentName: { contains: '品牌创意B部', mode: 'insensitive' } },
{ departmentPath: { contains: '品牌创意B部', mode: 'insensitive' } },
],
},
},
},
]),
})
expect(result).toEqual([
expect.objectContaining({
displayName: '清云',
positionLevel: 'M1',
isDingtalkAdmin: true,
departmentMemberships: [
expect.objectContaining({ departmentName: '营销策划中心', isPrimary: true }),
expect.objectContaining({ departmentName: '品牌创意B部', isPrimary: false }),
],
}),
])
})
test('创建接口拒绝直接传入组织范围外的被考核人 ID', async () => {
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValueOnce([baseEmployee])
@ -938,6 +1038,16 @@ describe('performance survey service policies', () => {
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
{
departmentMemberships: {
some: {
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
],
},
},
},
],
},
],

View File

@ -46,6 +46,20 @@ export type PerformanceSurveyPositionRuleInput = {
positionTitle?: string | null
}
export type PerformanceSurveyEmployeeDepartmentMembershipInput = {
departmentName?: string | null
departmentPath?: string | null
isPrimary?: boolean | null
}
export type PerformanceSurveyPositionEmployeeInput = {
id?: string | null
departmentPath?: string | null
departmentName?: string | null
positionTitle?: string | null
departmentMemberships?: PerformanceSurveyEmployeeDepartmentMembershipInput[] | null
}
export type PerformanceSurveyPositionMembership = {
status: 'matched' | 'unmatched' | 'conflict' | 'excluded'
group: PositionGroupRuntime | null
@ -181,24 +195,31 @@ function getCurrentAssessmentMonth() {
}
export function matchesPerformanceSurveyPositionRule(
employee: { departmentPath?: string | null; departmentName?: string | null; positionTitle?: string | null },
employee: PerformanceSurveyPositionEmployeeInput,
rule: PerformanceSurveyPositionRuleInput,
) {
const departmentPrefix = normalizeDepartmentPath(rule.departmentPathPrefix)
const positionTitle = normalizeMatchText(rule.positionTitle)
if (!departmentPrefix && !positionTitle) return false
const employeeDepartmentPath = normalizeDepartmentPath(employee.departmentPath || employee.departmentName)
const employeeDepartmentPaths = Array.from(new Set([
normalizeDepartmentPath(employee.departmentPath || employee.departmentName),
...(employee.departmentMemberships || []).map((membership) => (
normalizeDepartmentPath(membership.departmentPath || membership.departmentName)
)),
].filter((path): path is string => Boolean(path))))
const employeePositionTitle = normalizeMatchText(employee.positionTitle)
const departmentMatches = !departmentPrefix
|| employeeDepartmentPath === departmentPrefix
|| employeeDepartmentPath.startsWith(`${departmentPrefix}/`)
|| employeeDepartmentPaths.some((employeeDepartmentPath) => (
employeeDepartmentPath === departmentPrefix
|| employeeDepartmentPath.startsWith(`${departmentPrefix}/`)
))
const positionMatches = !positionTitle || employeePositionTitle === positionTitle
return departmentMatches && positionMatches
}
export function resolvePerformanceSurveyPositionMembership(
employee: { departmentPath?: string | null; departmentName?: string | null; positionTitle?: string | null },
employee: PerformanceSurveyPositionEmployeeInput,
groups: PositionGroupRuntime[],
manualAssignment?: ManualPositionAssignmentRuntime | null,
): PerformanceSurveyPositionMembership {
@ -642,14 +663,27 @@ function mapEffectiveManualPositionAssignments<T extends {
}
export async function getPerformanceSurveyPositionGroupForEmployee(
employee: { id?: string | null; departmentPath?: string | null; departmentName?: string | null; positionTitle?: string | null },
employee: PerformanceSurveyPositionEmployeeInput,
month?: string | null,
) {
const normalizedMonth = normalizePerformanceSurveyAssessmentMonth(month) || getCurrentAssessmentMonth()
const groups = await listPerformanceSurveyPositionGroups(normalizedMonth)
const assignments = employee.id
? await listEffectiveManualPositionAssignments(normalizedMonth, [employee.id])
: []
const [groups, assignments, departmentMemberships] = await Promise.all([
listPerformanceSurveyPositionGroups(normalizedMonth),
employee.id
? listEffectiveManualPositionAssignments(normalizedMonth, [employee.id])
: Promise.resolve([]),
employee.id
? prisma.performanceSurveyEmployeeDepartmentMembership.findMany({
where: { employeeProfileId: employee.id },
orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }],
select: {
departmentName: true,
departmentPath: true,
isPrimary: true,
},
})
: Promise.resolve(employee.departmentMemberships || []),
])
if (assignments.length > 1) {
return {
status: 'conflict' as const,
@ -659,22 +693,50 @@ export async function getPerformanceSurveyPositionGroupForEmployee(
manualAssignmentId: null,
}
}
return resolvePerformanceSurveyPositionMembership(employee, groups, assignments[0] || null)
return resolvePerformanceSurveyPositionMembership(
{ ...employee, departmentMemberships },
groups,
assignments[0] || null,
)
}
export async function getPerformanceSurveyPositionMembershipsForEmployees<
T extends {
id: string
departmentPath?: string | null
departmentName?: string | null
positionTitle?: string | null
},
T extends PerformanceSurveyPositionEmployeeInput & { id: string },
>(employees: T[], month?: string | null) {
const normalizedMonth = normalizePerformanceSurveyAssessmentMonth(month) || getCurrentAssessmentMonth()
const [groups, assignments] = await Promise.all([
const employeeIds = employees.map((employee) => employee.id)
const hasLoadedDepartmentMemberships = employees.every((employee) => (
Array.isArray(employee.departmentMemberships)
))
const [groups, assignments, departmentMemberships] = await Promise.all([
listPerformanceSurveyPositionGroups(normalizedMonth),
listEffectiveManualPositionAssignments(normalizedMonth, employees.map((employee) => employee.id)),
listEffectiveManualPositionAssignments(normalizedMonth, employeeIds),
hasLoadedDepartmentMemberships
? Promise.resolve(employees.flatMap((employee) => (
(employee.departmentMemberships || []).map((membership) => ({
employeeProfileId: employee.id,
...membership,
}))
)))
: employeeIds.length > 0
? prisma.performanceSurveyEmployeeDepartmentMembership.findMany({
where: { employeeProfileId: { in: employeeIds } },
orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }],
select: {
employeeProfileId: true,
departmentName: true,
departmentPath: true,
isPrimary: true,
},
})
: Promise.resolve([]),
])
const departmentsByEmployeeId = new Map<string, PerformanceSurveyEmployeeDepartmentMembershipInput[]>()
for (const membership of departmentMemberships) {
const existing = departmentsByEmployeeId.get(membership.employeeProfileId) || []
existing.push(membership)
departmentsByEmployeeId.set(membership.employeeProfileId, existing)
}
const assignmentByEmployeeId = mapEffectiveManualPositionAssignments(assignments)
const assignmentCountByEmployeeId = new Map<string, number>()
for (const assignment of assignments) {
@ -694,7 +756,12 @@ export async function getPerformanceSurveyPositionMembershipsForEmployees<
manualAssignmentId: null,
}
: resolvePerformanceSurveyPositionMembership(
employee,
{
...employee,
departmentMemberships: departmentsByEmployeeId.get(employee.id)
|| employee.departmentMemberships
|| [],
},
groups,
assignmentByEmployeeId.get(employee.id) || null,
),
@ -735,6 +802,11 @@ export async function getPerformanceSurveyPositionAdminConfig(monthInput?: strin
prisma.performanceSurveyEmployeeProfile.findMany({
where: await getPerformanceSurveyAccountProfileWhere(),
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
include: {
departmentMemberships: {
orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }],
},
},
}),
])
const manualAssignments = await listEffectiveManualPositionAssignments(month, employees.map((employee) => employee.id))
@ -1173,6 +1245,11 @@ async function assertPositionPlanHasNoConflicts(
listPerformanceSurveyPositionGroups(month),
prisma.performanceSurveyEmployeeProfile.findMany({
where: await getPerformanceSurveyAccountProfileWhere(),
include: {
departmentMemberships: {
orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }],
},
},
}),
])
const manualAssignments = await listEffectiveManualPositionAssignments(month, employees.map((employee) => employee.id))
@ -2125,6 +2202,18 @@ export async function listPerformanceSurveyManualPositionAssignments(params?: UR
{ departmentName: { contains: search, mode: 'insensitive' } },
{ departmentPath: { contains: search, mode: 'insensitive' } },
{ positionTitle: { contains: search, mode: 'insensitive' } },
{ positionLevel: { contains: search, mode: 'insensitive' } },
{ levelType: { contains: search, mode: 'insensitive' } },
{
departmentMemberships: {
some: {
OR: [
{ departmentName: { contains: search, mode: 'insensitive' } },
{ departmentPath: { contains: search, mode: 'insensitive' } },
],
},
},
},
],
}] : []),
],
@ -2139,6 +2228,23 @@ export async function listPerformanceSurveyManualPositionAssignments(params?: UR
departmentName: true,
departmentPath: true,
positionTitle: true,
positionLevel: true,
levelType: true,
isLeadership: true,
isDingtalkAdmin: true,
isDingtalkBoss: true,
isDingtalkSenior: true,
departmentMemberships: {
orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }],
select: {
id: true,
sourceDeptId: true,
departmentName: true,
departmentPath: true,
isPrimary: true,
syncedAt: true,
},
},
},
})
const memberships = await getPerformanceSurveyPositionMembershipsForEmployees(employees, month)
@ -2227,6 +2333,11 @@ export async function previewPerformanceSurveyPositionPlanImpact(input: {
prisma.performanceSurveyEmployeeProfile.findMany({
where: await getPerformanceSurveyAccountProfileWhere(),
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
include: {
departmentMemberships: {
orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }],
},
},
}),
])
const manualAssignments = await listEffectiveManualPositionAssignments(month, employees.map((employee) => employee.id))

View File

@ -2,6 +2,13 @@ import { randomUUID } from 'crypto'
import { prisma } from '@/lib/prisma'
import mssql from 'mssql'
type ResolvedEmployeeDepartment = {
sourceDeptId: string
departmentName: string | null
departmentPath: string | null
isPrimary: boolean
}
type ReportEmployeeRow = {
dingtalkUserId?: string | null
dingEmail?: string | null
@ -11,6 +18,9 @@ type ReportEmployeeRow = {
contactTitle?: string | null
workPlace?: string | null
extension?: string | null
isDingtalkAdmin?: string | number | boolean | null
isDingtalkBoss?: string | number | boolean | null
isDingtalkSenior?: string | number | boolean | null
registerName?: string | null
flowerName?: string | null
realName?: string | null
@ -38,6 +48,7 @@ type ReportEmployeeRow = {
flowerBookEmployeeStatus?: string | null
groupDepartmentName?: string | null
groupDepartmentPath?: string | null
resolvedDepartments?: ResolvedEmployeeDepartment[]
}
type ReportIdentity = {
@ -65,6 +76,9 @@ type EmployeeProfileSnapshot = {
positionLevel?: string | null
levelType?: string | null
isLeadership: boolean
isDingtalkAdmin?: boolean
isDingtalkBoss?: boolean
isDingtalkSenior?: boolean
managerDingtalkUserId?: string | null
managerJobNumber?: string | null
managerName?: string | null
@ -72,6 +86,7 @@ type EmployeeProfileSnapshot = {
workPlace?: string | null
sourcePayload?: unknown
syncedAt?: Date | null
departmentMemberships?: ResolvedEmployeeDepartment[]
}
type TableColumns = Map<string, string>
@ -120,6 +135,9 @@ const FIELD_CANDIDATES = {
workPlace: ['work_place', 'workPlace', 'workplace', 'office_location', '办公地点'],
extension: ['extension', 'ext', 'extattr', 'ext_attr'],
active: ['active', 'is_active', 'enabled'],
admin: ['admin', 'is_admin', 'administrator'],
boss: ['boss', 'is_boss'],
senior: ['senior', 'is_senior'],
employeeDeptIdList: ['dept_id_list', 'deptIdList', 'department_id_list', '部门id列表'],
registerName: ['sys00-name', 'name', '姓名'],
flowerName: ['sys00-flowerName', 'flower_name', 'flowerName', '花名'],
@ -204,6 +222,17 @@ function clean(value: unknown) {
return normalized.length > 0 ? normalized : null
}
function normalizeBoolean(value: unknown) {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value === 1
const normalized = clean(value)?.toLowerCase()
return normalized === '1'
|| normalized === 'true'
|| normalized === 'yes'
|| normalized === 'y'
|| normalized === '是'
}
const ACCOUNT_PROFILE_WHERE_CACHE_MS = Math.max(
5,
Number(process.env.PERFORMANCE_SURVEY_ACCOUNT_PROFILE_WHERE_CACHE_SECONDS || 60),
@ -328,16 +357,41 @@ function parseDepartmentIdParts(value?: string | null) {
.filter(Boolean)
}
function normalizeDepartmentId(value: unknown) {
const normalized = clean(value)
if (!normalized) return null
const numeric = Number(normalized)
return Number.isInteger(numeric) && numeric > 0 ? String(numeric) : normalized
}
function getFlowerBookDepartmentId(row: ReportEmployeeRow) {
const ids = parseDepartmentIdParts(row.flowerBookDeptId)
return normalizeDepartmentId(ids[ids.length - 1])
}
function getPrimaryDepartmentId(row: ReportEmployeeRow) {
return (
clean(row.registerMainDeptId) ||
parseDepartmentIdParts(row.registerDeptIds)[0] ||
clean(row.flowerBookDeptId) ||
parseDepartmentIdParts(row.employeeDeptIdList)[0] ||
normalizeDepartmentId(parseDepartmentIdParts(row.registerMainDeptId)[0]) ||
normalizeDepartmentId(parseDepartmentIdParts(row.registerDeptIds)[0]) ||
getFlowerBookDepartmentId(row) ||
normalizeDepartmentId(parseDepartmentIdParts(row.employeeDeptIdList)[0]) ||
null
)
}
function getAllDepartmentIds(row: ReportEmployeeRow) {
const primaryDepartmentId = getPrimaryDepartmentId(row)
const ids = [
primaryDepartmentId,
...parseDepartmentIdParts(row.registerDeptIds).map(normalizeDepartmentId),
...parseDepartmentIdParts(row.employeeDeptIdList).map(normalizeDepartmentId),
getFlowerBookDepartmentId(row),
].filter((id): id is string => Boolean(id))
return Array.from(new Set(ids))
}
async function resolveDepartmentPathsByIds(pool: any, ids: string[]) {
const numericIds = Array.from(
new Set(
@ -400,20 +454,40 @@ async function resolveDepartmentPathsByIds(pool: any, ids: string[]) {
}
async function applyResolvedDepartmentPaths(pool: any, rows: ReportEmployeeRow[]) {
const ids = rows.map((row) => getPrimaryDepartmentId(row)).filter((id): id is string => Boolean(id))
const ids = rows.flatMap((row) => getAllDepartmentIds(row))
if (ids.length === 0) return rows
const pathById = await resolveDepartmentPathsByIds(pool, ids)
return rows.map((row) => {
const deptId = getPrimaryDepartmentId(row)
const departmentPath = deptId ? clean(pathById.get(String(Number(deptId)))) : null
if (!departmentPath) return row
const primaryDepartmentId = getPrimaryDepartmentId(row)
const fallbackParts = getDepartmentParts(row)
const fallbackPath = joinDepartmentParts(fallbackParts)
const fallbackName = getDepartmentLeaf(fallbackParts)
const resolvedDepartments = getAllDepartmentIds(row).map((sourceDeptId) => {
const resolvedPath = clean(pathById.get(sourceDeptId))
const isPrimary = sourceDeptId === primaryDepartmentId
const departmentPath = resolvedPath || (isPrimary ? fallbackPath : null)
const parts = parseDepartmentParts(departmentPath)
return {
sourceDeptId,
departmentName: getDepartmentLeaf(parts) || (isPrimary ? fallbackName : null),
departmentPath,
isPrimary,
}
})
const primaryDepartment = resolvedDepartments.find((item) => item.isPrimary)
if (!primaryDepartment?.departmentPath) {
return {
...row,
resolvedDepartments,
}
}
const parts = parseDepartmentParts(departmentPath)
return {
...row,
groupDepartmentName: getDepartmentLeaf(parts),
groupDepartmentPath: departmentPath,
groupDepartmentName: primaryDepartment.departmentName,
groupDepartmentPath: primaryDepartment.departmentPath,
resolvedDepartments,
}
})
}
@ -537,6 +611,9 @@ function buildSelectSql(
workPlace: pickColumn(employeeColumns, FIELD_CANDIDATES.workPlace),
extension: pickColumn(employeeColumns, FIELD_CANDIDATES.extension),
active: pickColumn(employeeColumns, FIELD_CANDIDATES.active),
admin: pickColumn(employeeColumns, FIELD_CANDIDATES.admin),
boss: pickColumn(employeeColumns, FIELD_CANDIDATES.boss),
senior: pickColumn(employeeColumns, FIELD_CANDIDATES.senior),
deptIdList: pickColumn(employeeColumns, FIELD_CANDIDATES.employeeDeptIdList),
managerDingtalkUserId: pickColumn(employeeColumns, FIELD_CANDIDATES.managerDingtalkUserId),
managerJobNumber: pickColumn(employeeColumns, FIELD_CANDIDATES.managerJobNumber),
@ -598,6 +675,9 @@ function buildSelectSql(
${columnExpr('e', e.title)} AS "contactTitle",
${columnExpr('e', e.workPlace)} AS "workPlace",
${columnExpr('e', e.extension)} AS "extension",
${columnExpr('e', e.admin, 'bit')} AS "isDingtalkAdmin",
${columnExpr('e', e.boss, 'bit')} AS "isDingtalkBoss",
${columnExpr('e', e.senior, 'bit')} AS "isDingtalkSenior",
${columnExpr('e', e.deptIdList)} AS "employeeDeptIdList",
${columnExpr('r', r.registerName)} AS "registerName",
${columnExpr('r', r.flowerName)} AS "flowerName",
@ -643,6 +723,9 @@ function buildEmployeeListSql(employeeColumns: TableColumns, whereSql?: string)
workPlace: pickColumn(employeeColumns, FIELD_CANDIDATES.workPlace),
extension: pickColumn(employeeColumns, FIELD_CANDIDATES.extension),
active: pickColumn(employeeColumns, FIELD_CANDIDATES.active),
admin: pickColumn(employeeColumns, FIELD_CANDIDATES.admin),
boss: pickColumn(employeeColumns, FIELD_CANDIDATES.boss),
senior: pickColumn(employeeColumns, FIELD_CANDIDATES.senior),
deptIdList: pickColumn(employeeColumns, FIELD_CANDIDATES.employeeDeptIdList),
managerDingtalkUserId: pickColumn(employeeColumns, FIELD_CANDIDATES.managerDingtalkUserId),
managerJobNumber: pickColumn(employeeColumns, FIELD_CANDIDATES.managerJobNumber),
@ -663,6 +746,9 @@ function buildEmployeeListSql(employeeColumns: TableColumns, whereSql?: string)
${columnExpr('e', e.title)} AS "contactTitle",
${columnExpr('e', e.workPlace)} AS "workPlace",
${columnExpr('e', e.extension)} AS "extension",
${columnExpr('e', e.admin, 'bit')} AS "isDingtalkAdmin",
${columnExpr('e', e.boss, 'bit')} AS "isDingtalkBoss",
${columnExpr('e', e.senior, 'bit')} AS "isDingtalkSenior",
${columnExpr('e', e.deptIdList)} AS "employeeDeptIdList",
${columnExpr('e', e.managerDingtalkUserId)} AS "managerDingtalkUserId",
${columnExpr('e', e.managerJobNumber)} AS "managerJobNumber",
@ -1260,6 +1346,10 @@ function normalizeEmployeeRow(row: ReportEmployeeRow) {
const managerName = clean(row.flowerBookManager) || clean(row.flowerBookDirector) || clean(row.managerName) || clean(extension['主管'])
const employmentStatus = clean(row.flowerBookEmployeeStatus) || clean(row.employmentStatus)
const workPlace = clean(row.flowerBookWorkPlace) || clean(row.workPlace)
const departmentMemberships = row.resolvedDepartments || []
const isDingtalkAdmin = normalizeBoolean(row.isDingtalkAdmin)
const isDingtalkBoss = normalizeBoolean(row.isDingtalkBoss)
const isDingtalkSenior = normalizeBoolean(row.isDingtalkSenior)
return {
dingtalkUserId,
@ -1274,6 +1364,9 @@ function normalizeEmployeeRow(row: ReportEmployeeRow) {
positionLevel,
levelType,
isLeadership: isLeadershipLevel(positionLevel),
isDingtalkAdmin,
isDingtalkBoss,
isDingtalkSenior,
managerDingtalkUserId: clean(row.managerDingtalkUserId) || clean(extension['主管userid']),
managerJobNumber: clean(row.managerJobNumber) || clean(extension['主管工号']),
managerName,
@ -1289,6 +1382,7 @@ function normalizeEmployeeRow(row: ReportEmployeeRow) {
departmentName,
departmentPath,
primaryDeptId: getPrimaryDepartmentId(row),
departmentIds: departmentMemberships.map((item) => item.sourceDeptId),
employeeDeptIdList: clean(row.employeeDeptIdList),
registerDeptIds: clean(row.registerDeptIds),
registerMainDeptId: clean(row.registerMainDeptId),
@ -1296,8 +1390,12 @@ function normalizeEmployeeRow(row: ReportEmployeeRow) {
positionLevel,
levelType,
flowerBookRole: clean(row.flowerBookRole),
isDingtalkAdmin,
isDingtalkBoss,
isDingtalkSenior,
},
},
departmentMemberships,
syncedAt: new Date(),
}
}
@ -1320,13 +1418,16 @@ const PREVIEW_PROFILE_FIELDS = [
'positionLevel',
'levelType',
'isLeadership',
'isDingtalkAdmin',
'isDingtalkBoss',
'isDingtalkSenior',
'managerDingtalkUserId',
'managerJobNumber',
'managerName',
'employmentStatus',
'workPlace',
] as const
type PreviewChangedField = (typeof PREVIEW_PROFILE_FIELDS)[number] | 'sourcePayload'
type PreviewChangedField = (typeof PREVIEW_PROFILE_FIELDS)[number] | 'sourcePayload' | 'departmentMemberships'
function getSourcePayloadLink(payload: unknown) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
@ -1347,6 +1448,26 @@ function previewValueEquals(left: unknown, right: unknown) {
return clean(left) === clean(right)
}
function normalizePreviewDepartmentMemberships(value: unknown) {
if (!Array.isArray(value)) return []
return value
.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
const record = item as Record<string, unknown>
const sourceDeptId = normalizeDepartmentId(record.sourceDeptId)
if (!sourceDeptId) return null
return {
sourceDeptId,
departmentName: clean(record.departmentName),
departmentPath: clean(record.departmentPath),
isPrimary: Boolean(record.isPrimary),
}
})
.filter((item): item is ResolvedEmployeeDepartment => Boolean(item))
.sort((left, right) => left.sourceDeptId.localeCompare(right.sourceDeptId))
}
function hasBusinessProfileChanges(existing: EmployeeProfileSnapshot, proposed: PreviewProfileData) {
const changedFields: PreviewChangedField[] = PREVIEW_PROFILE_FIELDS.filter((field) => (
Object.prototype.hasOwnProperty.call(proposed, field)
@ -1365,6 +1486,14 @@ function hasBusinessProfileChanges(existing: EmployeeProfileSnapshot, proposed:
}
}
if (Object.prototype.hasOwnProperty.call(proposed, 'departmentMemberships')) {
const currentDepartments = normalizePreviewDepartmentMemberships(existing.departmentMemberships)
const proposedDepartments = normalizePreviewDepartmentMemberships(proposed.departmentMemberships)
if (JSON.stringify(currentDepartments) !== JSON.stringify(proposedDepartments)) {
changedFields.push('departmentMemberships')
}
}
return changedFields
}
@ -1571,19 +1700,42 @@ async function findExistingEmployeeProfile(data: Partial<ReturnType<typeof norma
async function upsertEmployeeProfile(data: ReturnType<typeof normalizeEmployeeRow>) {
const existing = await findExistingEmployeeProfile(data)
const { departmentMemberships, ...profileData } = data
const profileId = existing?.id || randomUUID()
if (existing) {
return prisma.performanceSurveyEmployeeProfile.update({
where: { id: existing.id },
data,
return prisma.$transaction(async (tx) => {
const profile = existing
? await tx.performanceSurveyEmployeeProfile.update({
where: { id: existing.id },
data: profileData,
})
: await tx.performanceSurveyEmployeeProfile.create({
data: {
id: profileId,
...profileData,
},
})
await tx.performanceSurveyEmployeeDepartmentMembership.deleteMany({
where: { employeeProfileId: profile.id },
})
}
return prisma.performanceSurveyEmployeeProfile.create({
data: {
id: randomUUID(),
...data,
},
if (departmentMemberships.length > 0) {
const syncedAt = new Date()
await tx.performanceSurveyEmployeeDepartmentMembership.createMany({
data: departmentMemberships.map((membership) => ({
id: randomUUID(),
employeeProfileId: profile.id,
sourceDeptId: membership.sourceDeptId,
departmentName: membership.departmentName,
departmentPath: membership.departmentPath,
isPrimary: membership.isPrimary,
syncedAt,
})),
})
}
return profile
})
}
@ -1607,18 +1759,37 @@ async function upsertDepartments() {
select: {
departmentName: true,
departmentPath: true,
departmentMemberships: {
select: {
departmentName: true,
departmentPath: true,
},
},
},
})
const grouped = new Map<string, { name: string; parentName: string | null; count: number }>()
for (const employee of employees) {
if (!employee.departmentName) continue
const existing = grouped.get(employee.departmentName)
grouped.set(employee.departmentName, {
name: employee.departmentName,
parentName: existing?.parentName || getParentDepartmentName(employee.departmentName, employee.departmentPath),
count: (existing?.count || 0) + 1,
})
const departments = employee.departmentMemberships.length > 0
? employee.departmentMemberships
: [{
departmentName: employee.departmentName,
departmentPath: employee.departmentPath,
}]
const seenNames = new Set<string>()
for (const department of departments) {
if (!department.departmentName || seenNames.has(department.departmentName)) continue
seenNames.add(department.departmentName)
const existing = grouped.get(department.departmentName)
grouped.set(department.departmentName, {
name: department.departmentName,
parentName: existing?.parentName || getParentDepartmentName(
department.departmentName,
department.departmentPath,
),
count: (existing?.count || 0) + 1,
})
}
}
let count = 0
@ -1873,6 +2044,9 @@ export async function previewPerformanceSurveyEmployeeSync(options?: { limit?: n
positionLevel: true,
levelType: true,
isLeadership: true,
isDingtalkAdmin: true,
isDingtalkBoss: true,
isDingtalkSenior: true,
managerDingtalkUserId: true,
managerJobNumber: true,
managerName: true,
@ -1880,6 +2054,14 @@ export async function previewPerformanceSurveyEmployeeSync(options?: { limit?: n
workPlace: true,
sourcePayload: true,
syncedAt: true,
departmentMemberships: {
select: {
sourceDeptId: true,
departmentName: true,
departmentPath: true,
isPrimary: true,
},
},
},
}),
])

View File

@ -275,6 +275,16 @@ function getScopeDepartmentPathWhere(scopeDepartmentPath: string) {
OR: [
{ departmentPath: scopeDepartmentPath },
{ departmentPath: { startsWith: `${scopeDepartmentPath}/` } },
{
departmentMemberships: {
some: {
OR: [
{ departmentPath: scopeDepartmentPath },
{ departmentPath: { startsWith: `${scopeDepartmentPath}/` } },
],
},
},
},
],
}
}
@ -287,7 +297,16 @@ function getContextScopeDepartmentWhere(ctx: UserContext) {
// 普通员工的旧档案可能只有部门名。管理权限不能使用这个回退,
// 否则同名叶子部门会跨组织命中。
if (!ctx.isDepartmentAdmin && ctx.scopeDepartment) {
return { departmentName: ctx.scopeDepartment }
return {
OR: [
{ departmentName: ctx.scopeDepartment },
{
departmentMemberships: {
some: { departmentName: ctx.scopeDepartment },
},
},
],
}
}
return null
@ -335,26 +354,54 @@ async function resolveManagedDepartmentPath(
{ departmentPath: { startsWith: `${managedName}/` } },
{ departmentPath: { contains: `/${managedName}/` } },
{ departmentPath: { endsWith: `/${managedName}` } },
{
departmentMemberships: {
some: {
OR: [
{ departmentName: managedName },
{ departmentPath: managedName },
{ departmentPath: { startsWith: `${managedName}/` } },
{ departmentPath: { contains: `/${managedName}/` } },
{ departmentPath: { endsWith: `/${managedName}` } },
],
},
},
},
],
},
select: {
departmentName: true,
departmentPath: true,
departmentMemberships: {
select: {
departmentName: true,
departmentPath: true,
},
},
},
take: 10_000,
})
const candidatePaths = new Set<string>()
for (const profile of profiles) {
const profileParts = parseDepartmentPathParts(profile.departmentPath)
if (profileParts.length === 0) continue
const matchedIndex = profileParts.lastIndexOf(managedName)
if (matchedIndex >= 0) {
candidatePaths.add(profileParts.slice(0, matchedIndex + 1).join('/'))
continue
}
if (clean(profile.departmentName) === managedName) {
candidatePaths.add(profileParts.join('/'))
const departmentCandidates = [
{
departmentName: profile.departmentName,
departmentPath: profile.departmentPath,
},
...profile.departmentMemberships,
]
for (const department of departmentCandidates) {
const profileParts = parseDepartmentPathParts(department.departmentPath)
if (profileParts.length === 0) continue
const matchedIndex = profileParts.lastIndexOf(managedName)
if (matchedIndex >= 0) {
candidatePaths.add(profileParts.slice(0, matchedIndex + 1).join('/'))
continue
}
if (clean(department.departmentName) === managedName) {
candidatePaths.add(profileParts.join('/'))
}
}
}
@ -412,6 +459,19 @@ function getDepartmentSelectionWhere(values: string[]) {
{ departmentPath: parsed.departmentPath },
{ departmentPath: { startsWith: `${parsed.departmentPath}/` } },
...(parsed.departmentPath.includes('/') ? [] : [{ departmentName: parsed.departmentName }]),
{
departmentMemberships: {
some: {
OR: [
{ departmentPath: parsed.departmentPath },
{ departmentPath: { startsWith: `${parsed.departmentPath}/` } },
...(parsed.departmentPath.includes('/')
? []
: [{ departmentName: parsed.departmentName }]),
],
},
},
},
],
})
} else {
@ -422,7 +482,16 @@ function getDepartmentSelectionWhere(values: string[]) {
const clauses: any[] = []
const uniqueDepartmentNames = Array.from(new Set(departmentNames))
if (uniqueDepartmentNames.length > 0) {
clauses.push({ departmentName: { in: uniqueDepartmentNames } })
clauses.push({
OR: [
{ departmentName: { in: uniqueDepartmentNames } },
{
departmentMemberships: {
some: { departmentName: { in: uniqueDepartmentNames } },
},
},
],
})
}
clauses.push(...departmentPathClauses)
@ -727,6 +796,19 @@ function getEmployeeSnapshot(employee: any) {
positionLevel: employee.positionLevel,
levelType: employee.levelType,
isLeadership: employee.isLeadership,
isDingtalkAdmin: employee.isDingtalkAdmin,
isDingtalkBoss: employee.isDingtalkBoss,
isDingtalkSenior: employee.isDingtalkSenior,
departmentMemberships: Array.isArray(employee.departmentMemberships)
? employee.departmentMemberships.map((membership: any) => ({
id: membership.id,
sourceDeptId: membership.sourceDeptId,
departmentName: membership.departmentName,
departmentPath: membership.departmentPath,
isPrimary: membership.isPrimary,
syncedAt: membership.syncedAt,
}))
: [],
managerDingtalkUserId: employee.managerDingtalkUserId,
managerJobNumber: employee.managerJobNumber,
managerName: employee.managerName,
@ -1409,6 +1491,16 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
{ positionLevel: { contains: search, mode: 'insensitive' } },
{ levelType: { contains: search, mode: 'insensitive' } },
{ managerName: { contains: search, mode: 'insensitive' } },
{
departmentMemberships: {
some: {
OR: [
{ departmentName: { contains: search, mode: 'insensitive' } },
{ departmentPath: { contains: search, mode: 'insensitive' } },
],
},
},
},
] })
}
return { AND: and } as any
@ -1418,6 +1510,11 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
where: await buildWhere(true),
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
...(positionGroupCode ? {} : { take: limit }),
include: {
departmentMemberships: {
orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }],
},
},
})
let employees = await queryEmployees()
@ -1428,6 +1525,11 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
where: await buildWhere(false),
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
take: Math.min(5000, Math.max(1000, limit * 20)),
include: {
departmentMemberships: {
orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }],
},
},
})
const existingIds = new Set(employees.map((employee) => employee.id))
const pinyinMatches = candidates.filter((employee) => {
@ -1444,6 +1546,10 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
employee.positionLevel,
employee.levelType,
employee.managerName,
...employee.departmentMemberships.flatMap((membership) => [
membership.departmentName,
membership.departmentPath,
]),
])
})
employees = [...employees, ...pinyinMatches].slice(0, limit)
@ -1513,6 +1619,13 @@ export async function listPerformanceSurveyDepartments(session: Session | null,
select: {
departmentName: true,
departmentPath: true,
departmentMemberships: {
orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }],
select: {
departmentName: true,
departmentPath: true,
},
},
},
},
},
@ -1523,7 +1636,12 @@ export async function listPerformanceSurveyDepartments(session: Session | null,
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
const and: any[] = [
accountWhere,
{ departmentName: { not: null } },
{
OR: [
{ departmentName: { not: null } },
{ departmentMemberships: { some: {} } },
],
},
]
if (!ctx.isSystemAdmin && !isEvaluatorPicker) {
and.push(getContextScopeDepartmentWhere(ctx) || { id: '__none__' })
@ -1533,38 +1651,57 @@ export async function listPerformanceSurveyDepartments(session: Session | null,
select: {
departmentName: true,
departmentPath: true,
departmentMemberships: {
orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }],
select: {
departmentName: true,
departmentPath: true,
},
},
},
})
})()
const nodeByPath = new Map<string, any>()
for (const employee of employees) {
const departmentName = clean(employee.departmentName)
const pathParts = parseDepartmentPathParts(employee.departmentPath)
const parts = pathParts.length > 0 ? pathParts : departmentName ? [departmentName] : []
if (parts.length === 0) continue
const departmentCandidates = [
{
departmentName: employee.departmentName,
departmentPath: employee.departmentPath,
},
...employee.departmentMemberships,
]
const countedPaths = new Set<string>()
for (const department of departmentCandidates) {
const departmentName = clean(department.departmentName)
const pathParts = parseDepartmentPathParts(department.departmentPath)
const parts = pathParts.length > 0 ? pathParts : departmentName ? [departmentName] : []
if (parts.length === 0) continue
for (let index = 0; index < parts.length; index += 1) {
const fullPath = parts.slice(0, index + 1).join('/')
const parentPath = index > 0 ? parts.slice(0, index).join('/') : null
const existing = nodeByPath.get(fullPath)
if (existing) {
existing.employeeCount += 1
continue
for (let index = 0; index < parts.length; index += 1) {
const fullPath = parts.slice(0, index + 1).join('/')
if (countedPaths.has(fullPath)) continue
countedPaths.add(fullPath)
const parentPath = index > 0 ? parts.slice(0, index).join('/') : null
const existing = nodeByPath.get(fullPath)
if (existing) {
existing.employeeCount += 1
continue
}
nodeByPath.set(fullPath, {
id: getDepartmentNodeId(fullPath),
name: parts[index],
parentId: parentPath ? getDepartmentNodeId(parentPath) : ALL_DEPARTMENTS_NODE_ID,
parentName: parentPath ? parts[index - 1] : ALL_DEPARTMENTS_NODE_NAME,
employeeCount: 1,
selectable: true,
value: getDepartmentPathValue(fullPath),
departmentName: parts[index],
departmentPath: fullPath,
fullPath,
})
}
nodeByPath.set(fullPath, {
id: getDepartmentNodeId(fullPath),
name: parts[index],
parentId: parentPath ? getDepartmentNodeId(parentPath) : ALL_DEPARTMENTS_NODE_ID,
parentName: parentPath ? parts[index - 1] : ALL_DEPARTMENTS_NODE_NAME,
employeeCount: 1,
selectable: true,
value: getDepartmentPathValue(fullPath),
departmentName: parts[index],
departmentPath: fullPath,
fullPath,
})
}
}
@ -4756,6 +4893,19 @@ function serializeEmployee(employee: any) {
positionLevel: employee.positionLevel,
levelType: employee.levelType,
isLeadership: employee.isLeadership,
isDingtalkAdmin: employee.isDingtalkAdmin,
isDingtalkBoss: employee.isDingtalkBoss,
isDingtalkSenior: employee.isDingtalkSenior,
departmentMemberships: Array.isArray(employee.departmentMemberships)
? employee.departmentMemberships.map((membership: any) => ({
id: membership.id,
sourceDeptId: membership.sourceDeptId,
departmentName: membership.departmentName,
departmentPath: membership.departmentPath,
isPrimary: membership.isPrimary,
syncedAt: membership.syncedAt,
}))
: [],
managerDingtalkUserId: employee.managerDingtalkUserId,
managerJobNumber: employee.managerJobNumber,
managerName: employee.managerName,

View File

@ -0,0 +1,76 @@
ALTER TABLE "performance_survey_employee_profiles"
ADD COLUMN IF NOT EXISTS "is_dingtalk_admin" BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS "is_dingtalk_boss" BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS "is_dingtalk_senior" BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS "performance_survey_employee_profiles_is_dingtalk_admin_idx"
ON "performance_survey_employee_profiles" ("is_dingtalk_admin");
CREATE TABLE IF NOT EXISTS "performance_survey_employee_department_memberships" (
"id" TEXT PRIMARY KEY,
"employee_profile_id" TEXT NOT NULL,
"source_dept_id" TEXT NOT NULL,
"department_name" TEXT,
"department_path" TEXT,
"is_primary" BOOLEAN NOT NULL DEFAULT FALSE,
"synced_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "performance_survey_employee_department_memberships_profile_fkey"
FOREIGN KEY ("employee_profile_id") REFERENCES "performance_survey_employee_profiles"("id")
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "ps_employee_dept_profile_dept_key"
ON "performance_survey_employee_department_memberships" ("employee_profile_id", "source_dept_id");
CREATE UNIQUE INDEX IF NOT EXISTS "ps_employee_dept_one_primary_key"
ON "performance_survey_employee_department_memberships" ("employee_profile_id")
WHERE "is_primary" = TRUE;
CREATE INDEX IF NOT EXISTS "ps_employee_dept_source_dept_idx"
ON "performance_survey_employee_department_memberships" ("source_dept_id");
CREATE INDEX IF NOT EXISTS "ps_employee_dept_name_idx"
ON "performance_survey_employee_department_memberships" ("department_name");
CREATE INDEX IF NOT EXISTS "ps_employee_dept_path_idx"
ON "performance_survey_employee_department_memberships" ("department_path");
CREATE INDEX IF NOT EXISTS "ps_employee_dept_profile_primary_idx"
ON "performance_survey_employee_department_memberships" ("employee_profile_id", "is_primary");
INSERT INTO "performance_survey_employee_department_memberships" (
"id",
"employee_profile_id",
"source_dept_id",
"department_name",
"department_path",
"is_primary",
"synced_at",
"created_at",
"updated_at"
)
SELECT
md5(
profile."id"
|| ':'
|| (profile."source_payload" #>> '{syncedFields,primaryDeptId}')
),
profile."id",
profile."source_payload" #>> '{syncedFields,primaryDeptId}',
profile."department_name",
profile."department_path",
TRUE,
profile."synced_at",
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "performance_survey_employee_profiles" profile
WHERE NULLIF(profile."source_payload" #>> '{syncedFields,primaryDeptId}', '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM "performance_survey_employee_department_memberships" existing
WHERE existing."employee_profile_id" = profile."id"
)
ON CONFLICT ("employee_profile_id", "source_dept_id") DO UPDATE
SET
"department_name" = EXCLUDED."department_name",
"department_path" = EXCLUDED."department_path",
"is_primary" = TRUE,
"synced_at" = EXCLUDED."synced_at",
"updated_at" = CURRENT_TIMESTAMP;

View File

@ -506,6 +506,9 @@ model PerformanceSurveyEmployeeProfile {
positionLevel String? @map("position_level")
levelType String? @map("level_type")
isLeadership Boolean @default(false) @map("is_leadership")
isDingtalkAdmin Boolean @default(false) @map("is_dingtalk_admin")
isDingtalkBoss Boolean @default(false) @map("is_dingtalk_boss")
isDingtalkSenior Boolean @default(false) @map("is_dingtalk_senior")
managerDingtalkUserId String? @map("manager_dingtalk_user_id")
managerJobNumber String? @map("manager_job_number")
managerName String? @map("manager_name")
@ -522,6 +525,7 @@ model PerformanceSurveyEmployeeProfile {
workItemScores PerformanceSurveyWorkItemScore[] @relation("PerformanceSurveyWorkItemScoreEvaluator")
notifications PerformanceSurveyNotification[]
manualPositionAssignments PerformanceSurveyEmployeePositionAssignment[]
departmentMemberships PerformanceSurveyEmployeeDepartmentMembership[]
@@index([displayName])
@@index([dingEmail])
@ -529,11 +533,32 @@ model PerformanceSurveyEmployeeProfile {
@@index([positionLevel])
@@index([levelType])
@@index([isLeadership])
@@index([isDingtalkAdmin])
@@index([managerDingtalkUserId])
@@index([employmentStatus])
@@map("performance_survey_employee_profiles")
}
model PerformanceSurveyEmployeeDepartmentMembership {
id String @id @default(uuid())
employeeProfileId String @map("employee_profile_id")
sourceDeptId String @map("source_dept_id")
departmentName String? @map("department_name")
departmentPath String? @map("department_path")
isPrimary Boolean @default(false) @map("is_primary")
syncedAt DateTime? @map("synced_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
employeeProfile PerformanceSurveyEmployeeProfile @relation(fields: [employeeProfileId], references: [id], onDelete: Cascade)
@@unique([employeeProfileId, sourceDeptId], map: "ps_employee_dept_profile_dept_key")
@@index([sourceDeptId], map: "ps_employee_dept_source_dept_idx")
@@index([departmentName], map: "ps_employee_dept_name_idx")
@@index([departmentPath], map: "ps_employee_dept_path_idx")
@@index([employeeProfileId, isPrimary], map: "ps_employee_dept_profile_primary_idx")
@@map("performance_survey_employee_department_memberships")
}
model PerformanceSurveyDepartmentProfile {
id String @id @default(uuid())
name String @unique