diff --git a/app/performance-survey/PerformanceSurveyAdminPeople.test.tsx b/app/performance-survey/PerformanceSurveyAdminPeople.test.tsx index 9e4c041..556e059 100644 --- a/app/performance-survey/PerformanceSurveyAdminPeople.test.tsx +++ b/app/performance-survey/PerformanceSurveyAdminPeople.test.tsx @@ -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 + + 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')) diff --git a/app/performance-survey/PerformanceSurveyAdminPeople.tsx b/app/performance-survey/PerformanceSurveyAdminPeople.tsx index 1c4997a..19aa08f 100644 --- a/app/performance-survey/PerformanceSurveyAdminPeople.tsx +++ b/app/performance-survey/PerformanceSurveyAdminPeople.tsx @@ -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 ( + + {labels.map((label) => ( + + {label} + + ))} + + ) +} + +function DepartmentSummary({ row, compact = false }: { row: AssignmentRow; compact?: boolean }) { + const { primaryLabel, secondaryLabels } = readDepartmentSummary(row) + const secondarySummary = summarizeDepartments(secondaryLabels) + const allSecondaryDepartments = secondaryLabels.join('、') + + return ( + + + 主部门 + + {primaryLabel} + + + {secondaryLabels.length > 0 && ( + + 兼任 + + {secondarySummary} + + + )} + + ) +} + +function PositionSummary({ row }: { row: AssignmentRow }) { + const level = readPositionLevel(row) + return ( + + {row.positionTitle || '职位未填写'} + {(level || row.isLeadership) && ( + + {level && ( + + {level} + + )} + {row.isLeadership && ( + + 管理岗 + + )} + + )} + + ) +} + 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]" /> @@ -380,8 +496,8 @@ export function PerformanceSurveyAdminPeople({ 人员 - 部门 - 职位 + 部门归属 + 职位 / 职级 当前分配 @@ -404,9 +520,12 @@ export function PerformanceSurveyAdminPeople({ /> - {row.displayName} - {row.departmentPath || row.departmentName || '-'} - {row.positionTitle || '-'} + + {row.displayName} + + + + - {row.displayName} + + {row.displayName} + + - - {row.departmentPath || row.departmentName || '部门未填写'} - - {row.positionTitle || '职位未填写'} + + + + 职位 + + diff --git a/lib/__tests__/performance-survey-employee-sync-preview.test.ts b/lib/__tests__/performance-survey-employee-sync-preview.test.ts index 3d82d4f..e12c5ba 100644 --- a/lib/__tests__/performance-survey-employee-sync-preview.test.ts +++ b/lib/__tests__/performance-survey-employee-sync-preview.test.ts @@ -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() + 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) }) }) diff --git a/lib/__tests__/performance-survey-position-config.test.ts b/lib/__tests__/performance-survey-position-config.test.ts index 4ff4ae1..3f84df1 100644 --- a/lib/__tests__/performance-survey-position-config.test.ts +++ b/lib/__tests__/performance-survey-position-config.test.ts @@ -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', diff --git a/lib/__tests__/performance-survey-service-policy.test.ts b/lib/__tests__/performance-survey-service-policy.test.ts index 79277f4..1c2a72a 100644 --- a/lib/__tests__/performance-survey-service-policy.test.ts +++ b/lib/__tests__/performance-survey-service-policy.test.ts @@ -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/品牌中心/策划部/' } }, + ], + }, + }, + }, ], }, ], diff --git a/lib/performance-survey-position-config.ts b/lib/performance-survey-position-config.ts index 524d759..9d18a27 100644 --- a/lib/performance-survey-position-config.ts +++ b/lib/performance-survey-position-config.ts @@ -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 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() + 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() 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)) diff --git a/lib/performance-survey-report-sync.ts b/lib/performance-survey-report-sync.ts index 216f247..a639384 100644 --- a/lib/performance-survey-report-sync.ts +++ b/lib/performance-survey-report-sync.ts @@ -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 @@ -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 + 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) { 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() 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() + 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, + }, + }, }, }), ]) diff --git a/lib/performance-survey-service.ts b/lib/performance-survey-service.ts index c6681a1..a2f084b 100644 --- a/lib/performance-survey-service.ts +++ b/lib/performance-survey-service.ts @@ -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() 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() 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() + 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, diff --git a/prisma/migrations/20260727_add_performance_survey_employee_departments/migration.sql b/prisma/migrations/20260727_add_performance_survey_employee_departments/migration.sql new file mode 100644 index 0000000..9ae253c --- /dev/null +++ b/prisma/migrations/20260727_add_performance_survey_employee_departments/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 875e535..f7f0ac4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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