fix: 完善考核调研人员多部门归属
This commit is contained in:
parent
063bbca0ef
commit
c35ab8c663
@ -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'))
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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/品牌中心/策划部/' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user