diff --git a/lib/__tests__/performance-survey-employee-sync-preview.test.ts b/lib/__tests__/performance-survey-employee-sync-preview.test.ts index 54b6437..4ff86b0 100644 --- a/lib/__tests__/performance-survey-employee-sync-preview.test.ts +++ b/lib/__tests__/performance-survey-employee-sync-preview.test.ts @@ -6,6 +6,7 @@ import { comparePerformanceSurveyEmployeeSyncPreview, previewPerformanceSurveyEmployeeSync, syncPerformanceSurveyEmployeeByIdentity, + syncPerformanceSurveyEmployees, upsertPerformanceSurveyEmployeeProfileForSync, } from '@/lib/performance-survey-report-sync' @@ -255,6 +256,31 @@ describe('performance survey employee sync preview', () => { }) }) + test('只读预检会纳入没有素材库账号的在职花名册人员', () => { + const result = comparePerformanceSurveyEmployeeSyncPreview({ + identities: [], + rows: [{ + dingtalkUserId: 'ding-xiangzhang', + jobNumber: 'JS06002901', + contactName: '香樟', + realName: '马震宇', + departmentName: '品牌设计部', + positionTitle: '品牌设计师', + positionLevel: 'P6', + employmentStatus: '试用', + }], + existingProfiles: [], + }) + + expect(result).toMatchObject({ + fetched: 1, + matchedAccounts: 0, + created: 1, + fallback: 0, + plannedProfileWrites: 1, + }) + }) + test('未配置上游时只返回兜底预检,且不会写入本地数据库', async () => { ;(prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: 'user-1', @@ -384,6 +410,89 @@ describe('performance survey employee sync preview', () => { 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.user.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([]) + + const profileCreate = jest.fn().mockResolvedValue({ id: 'profile-xiangzhang' }) + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({ + performanceSurveyEmployeeProfile: { + findMany: jest.fn().mockResolvedValue([]), + findFirst: jest.fn().mockResolvedValue(null), + create: profileCreate, + update: jest.fn(), + }, + performanceSurveyEmployeeDepartmentMembership: { + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + createMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + })) + + 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') + return { + recordset: table === 'hr_employee' + ? ['userid', 'email', 'job_number', 'name', 'title', 'active'] + .map((COLUMN_NAME) => ({ COLUMN_NAME })) + : [], + } + } + if (query.includes('[rpt].[hr_employee]')) { + return { + recordset: [{ + dingtalkUserId: 'ding-xiangzhang', + jobNumber: 'JS06002901', + contactName: '香樟', + contactTitle: '品牌设计师', + }], + } + } + throw new Error(`未预期的查询:${query}`) + }) + return request + }), + close: jest.fn(), + } + ;(mssql.connect as jest.Mock).mockResolvedValue(pool) + + await expect(syncPerformanceSurveyEmployees({ limit: 3000 })).resolves.toMatchObject({ + scannedAccounts: 0, + scannedProfiles: 1, + upserted: 1, + fallback: 0, + failed: 0, + }) + expect(profileCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + dingtalkUserId: 'ding-xiangzhang', + jobNumber: 'JS06002901', + displayName: '香樟', + positionTitle: '品牌设计师', + }), + }) + expect(pool.close).toHaveBeenCalledTimes(1) + }) + test('单人正式同步会保存全部所属部门和钉钉身份字段,且不映射为系统权限', async () => { process.env.REPORT_DB_SERVER = 'report.example.test' process.env.REPORT_DB_DATABASE = 'report' @@ -555,6 +664,11 @@ describe('performance survey employee sync preview', () => { dingtalkUserId: 'ding-huai-de', jobNumber: null, dingEmail: 'huai.de@example.com', + sourcePayload: { + source: 'JSReportReplica.rpt', + materialUserId: 'material-user-huai-de', + materialUserEmail: 'huai.de@example.com', + }, createdAt: new Date('2026-07-01T00:00:00.000Z'), } const jobNumberProfile = { @@ -661,6 +775,11 @@ describe('performance survey employee sync preview', () => { flowerName: '怀德', realName: '吾正鹏', displayName: '怀德', + sourcePayload: expect.objectContaining({ + source: 'JSReportReplica.rpt', + materialUserId: 'material-user-huai-de', + materialUserEmail: 'huai.de@example.com', + }), }), }) expect(membershipDeleteMany).toHaveBeenCalledWith({ diff --git a/lib/__tests__/performance-survey-position-admin.test.ts b/lib/__tests__/performance-survey-position-admin.test.ts index 3c6d750..ffa2238 100644 --- a/lib/__tests__/performance-survey-position-admin.test.ts +++ b/lib/__tests__/performance-survey-position-admin.test.ts @@ -5,6 +5,7 @@ import { assertPerformanceSurveyPositionPlanPreviewMatches, createPerformanceSurveyManualPositionAssignments, getPerformanceSurveyPositionAdminConfig, + listPerformanceSurveyManualPositionAssignments, listPerformanceSurveyPositionGroups, removePerformanceSurveyTemplate, savePerformanceSurveyPositionGroupDraft, @@ -39,6 +40,9 @@ jest.mock('@/lib/prisma', () => ({ performanceSurveyEmployeeProfile: { findMany: jest.fn(), }, + performanceSurveyEmployeePositionAssignment: { + findMany: jest.fn(), + }, $transaction: jest.fn(), }, })) @@ -216,6 +220,45 @@ describe('performance survey position admin', () => { }) }) + test('管理员人员分配可搜索没有素材库账号的在职花名册人员', async () => { + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([{ + id: 'employee-xiangzhang', + displayName: '香樟', + flowerName: '香樟', + realName: '马震宇', + jobNumber: 'JS06002901', + departmentName: '品牌设计部', + departmentPath: '江苏简尚品牌管理有限公司/品牌中心/品牌设计部', + positionTitle: '品牌设计师', + positionLevel: 'P6', + levelType: 'P', + isLeadership: false, + isDingtalkAdmin: false, + isDingtalkBoss: false, + isDingtalkSenior: false, + departmentMemberships: [], + }]) + ;(prisma.performanceSurveyPositionGroup.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.performanceSurveyEmployeePositionAssignment.findMany as jest.Mock).mockResolvedValue([]) + + const params = new URLSearchParams({ + month: '2026-07', + search: '香樟', + }) + await expect(listPerformanceSurveyManualPositionAssignments(params)).resolves.toMatchObject({ + total: 1, + items: [{ + employeeId: 'employee-xiangzhang', + displayName: '香樟', + }], + }) + + const query = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[0][0] + expect(JSON.stringify(query.where)).toContain('employmentStatus') + expect(JSON.stringify(query.where)).not.toContain('materialUserId') + expect(JSON.stringify(query.where)).not.toContain('materialUserEmail') + }) + test('模板管理配置只查询模板,不扫描岗位和人员档案', async () => { ;(prisma.performanceSurveyTemplate.findMany as jest.Mock).mockResolvedValue([{ id: 'template-1', diff --git a/lib/performance-survey-position-config.ts b/lib/performance-survey-position-config.ts index 4123580..ae11ffe 100644 --- a/lib/performance-survey-position-config.ts +++ b/lib/performance-survey-position-config.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { prisma } from '@/lib/prisma' -import { getPerformanceSurveyAccountProfileWhere } from '@/lib/performance-survey-report-sync' +import { getActivePerformanceSurveyEmployeeProfileWhere } from '@/lib/performance-survey-report-sync' import { DEFAULT_PERFORMANCE_SURVEY_TEMPLATES, PERFORMANCE_SURVEY_VISUAL_TEMPLATE_VARIANTS, @@ -977,7 +977,7 @@ export async function getPerformanceSurveyPositionAdminConfig( include: { versions: { orderBy: { version: 'desc' } } }, }), prisma.performanceSurveyEmployeeProfile.findMany({ - where: await getPerformanceSurveyAccountProfileWhere(), + where: getActivePerformanceSurveyEmployeeProfileWhere(), orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }], include: { departmentMemberships: { @@ -1410,7 +1410,7 @@ async function assertPositionPlanHasNoConflicts( const [groups, employees] = await Promise.all([ listPerformanceSurveyPositionGroups(month), prisma.performanceSurveyEmployeeProfile.findMany({ - where: await getPerformanceSurveyAccountProfileWhere(), + where: getActivePerformanceSurveyEmployeeProfileWhere(), include: { departmentMemberships: { orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }], @@ -2240,7 +2240,12 @@ export async function createPerformanceSurveyManualPositionAssignments(input: { const normalized = normalizeManualPositionAssignmentInput(input) await assertManualAssignmentPositionGroup(normalized.positionGroupId) const employees = await prisma.performanceSurveyEmployeeProfile.findMany({ - where: { id: { in: employeeIds } }, + where: { + AND: [ + getActivePerformanceSurveyEmployeeProfileWhere(), + { id: { in: employeeIds } }, + ], + }, select: { id: true, displayName: true }, }) if (employees.length !== employeeIds.length) throw new PerformanceSurveyPositionConfigError('部分人员不存在或已不可用') @@ -2392,11 +2397,10 @@ export async function listPerformanceSurveyManualPositionAssignments(params?: UR throw new PerformanceSurveyPositionConfigError('人员分配状态筛选无效') } - const accountWhere = await getPerformanceSurveyAccountProfileWhere() const employees = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ - accountWhere, + getActivePerformanceSurveyEmployeeProfileWhere(), ...(employeeId ? [{ id: employeeId }] : []), ...(search ? [{ OR: [ @@ -2535,7 +2539,7 @@ export async function previewPerformanceSurveyPositionPlanImpact(input: { const [currentGroups, employees] = await Promise.all([ listPerformanceSurveyPositionGroups(month), prisma.performanceSurveyEmployeeProfile.findMany({ - where: await getPerformanceSurveyAccountProfileWhere(), + where: getActivePerformanceSurveyEmployeeProfileWhere(), orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }], include: { departmentMemberships: { diff --git a/lib/performance-survey-report-sync.ts b/lib/performance-survey-report-sync.ts index d68982f..4c0de0f 100644 --- a/lib/performance-survey-report-sync.ts +++ b/lib/performance-survey-report-sync.ts @@ -779,6 +779,12 @@ function buildRegisterListSql(registerColumns: TableColumns, whereSql?: string) managerName: pickColumn(registerColumns, FIELD_CANDIDATES.managerName), } + const whereParts = [ + notInactiveStatusCondition('r', r.employmentStatus), + whereSql ? `(${whereSql})` : null, + ].filter(Boolean) + const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '' + return ` SELECT TOP (@limit) ${columnExpr('r', r.dingtalkUserId)} AS "dingtalkUserId", @@ -798,7 +804,7 @@ function buildRegisterListSql(registerColumns: TableColumns, whereSql?: string) ${columnExpr('r', r.managerJobNumber)} AS "managerJobNumber", ${columnExpr('r', r.managerName)} AS "managerName" FROM ${quoteIdentifier(REGISTER_TABLE.schema)}.${quoteIdentifier(REGISTER_TABLE.table)} r - ${whereSql ? `WHERE ${whereSql}` : ''} + ${whereClause} ORDER BY COALESCE(${columnExpr('r', r.flowerName)}, ${columnExpr('r', r.registerName)}, ${columnExpr('r', r.jobNumber)}, ${columnExpr('r', r.dingtalkUserId)}) ASC ` } @@ -992,84 +998,6 @@ function getIdentityProfileMatchScore(identity: ReportIdentity, profile: ReturnT return getIdentityNames(identity).some((name) => profileNames.has(name.toLowerCase())) ? 1 : 0 } -function getAccountScopedValues(identities: ReportIdentity[]) { - return { - dingtalkUserIds: uniqueClean(identities.map((item) => item.dingtalkUserId)), - emails: uniqueClean(identities.map((item) => item.email)), - names: uniqueClean(identities.flatMap((item) => getIdentityNames(item))), - } -} - -function bindAccountScopedValues( - request: any, - sql: typeof import('mssql'), - values: ReturnType, -) { - values.dingtalkUserIds.forEach((value, index) => request.input(`ding${index}`, sql.NVarChar(200), value)) - values.emails.forEach((value, index) => request.input(`email${index}`, sql.NVarChar(320), value)) - values.names.forEach((value, index) => request.input(`name${index}`, sql.NVarChar(200), value)) -} - -function inCondition(alias: string, column: string | null, prefix: string, count: number) { - if (!column || count === 0) return null - const params = Array.from({ length: count }, (_, index) => `@${prefix}${index}`).join(', ') - return `${alias}.${quoteIdentifier(column)} IN (${params})` -} - -function buildAccountScopedSelectWhere( - schema: Awaited>, - values: ReturnType, -) { - const e = { - dingtalkUserId: pickColumn(schema.employeeColumns, FIELD_CANDIDATES.dingtalkUserId), - email: pickColumn(schema.employeeColumns, FIELD_CANDIDATES.email), - name: pickColumn(schema.employeeColumns, FIELD_CANDIDATES.name), - } - const r = { - dingtalkUserId: pickColumn(schema.registerColumns, FIELD_CANDIDATES.dingtalkUserId), - email: pickColumn(schema.registerColumns, FIELD_CANDIDATES.email), - flowerName: pickColumn(schema.registerColumns, FIELD_CANDIDATES.flowerName), - realName: pickColumn(schema.registerColumns, FIELD_CANDIDATES.registerRealName), - registerName: pickColumn(schema.registerColumns, FIELD_CANDIDATES.registerName), - } - const f = { - flowerName: pickColumn(schema.flowerColumns, FIELD_CANDIDATES.flowerBookFlowerName), - name: pickColumn(schema.flowerColumns, FIELD_CANDIDATES.flowerBookName), - } - const conditions = [ - inCondition('e', e.dingtalkUserId, 'ding', values.dingtalkUserIds.length), - inCondition('r', r.dingtalkUserId, 'ding', values.dingtalkUserIds.length), - inCondition('e', e.email, 'email', values.emails.length), - inCondition('r', r.email, 'email', values.emails.length), - inCondition('e', e.name, 'name', values.names.length), - inCondition('r', r.flowerName, 'name', values.names.length), - inCondition('r', r.realName, 'name', values.names.length), - inCondition('r', r.registerName, 'name', values.names.length), - inCondition('f', f.flowerName, 'name', values.names.length), - inCondition('f', f.name, 'name', values.names.length), - ].filter(Boolean) - - return conditions.length > 0 ? `(${conditions.join(' OR ')})` : '1 = 0' -} - -function buildAccountScopedAliasWhere( - alias: string, - columns: { - dingtalkUserId?: string | null - email?: string | null - names?: Array - }, - values: ReturnType, -) { - const conditions = [ - inCondition(alias, columns.dingtalkUserId || null, 'ding', values.dingtalkUserIds.length), - inCondition(alias, columns.email || null, 'email', values.emails.length), - ...(columns.names || []).map((column) => inCondition(alias, column, 'name', values.names.length)), - ].filter(Boolean) - - return conditions.length > 0 ? `(${conditions.join(' OR ')})` : '1 = 0' -} - function getIndexed(index: Map, ...keys: unknown[]) { for (const key of keys) { const normalized = normalizedKey(key) @@ -1153,121 +1081,6 @@ async function fetchFullSyncRows( return applyResolvedDepartmentPaths(pool, mergedRows.slice(0, limit)) } -async function fetchAccountScopedSyncRows( - pool: any, - sql: typeof import('mssql'), - schema: Awaited>, - identities: ReportIdentity[], - limit: number, -) { - const values = getAccountScopedValues(identities) - const scopedLimit = Math.max(limit, identities.length * 3, 100) - const bindInputs = (request: any) => bindAccountScopedValues(request, sql, values) - - const employeeColumns = { - dingtalkUserId: pickColumn(schema.employeeColumns, FIELD_CANDIDATES.dingtalkUserId), - email: pickColumn(schema.employeeColumns, FIELD_CANDIDATES.email), - name: pickColumn(schema.employeeColumns, FIELD_CANDIDATES.name), - } - const registerColumns = { - dingtalkUserId: pickColumn(schema.registerColumns, FIELD_CANDIDATES.dingtalkUserId), - email: pickColumn(schema.registerColumns, FIELD_CANDIDATES.email), - flowerName: pickColumn(schema.registerColumns, FIELD_CANDIDATES.flowerName), - realName: pickColumn(schema.registerColumns, FIELD_CANDIDATES.registerRealName), - registerName: pickColumn(schema.registerColumns, FIELD_CANDIDATES.registerName), - } - const flowerColumns = { - flowerName: pickColumn(schema.flowerColumns, FIELD_CANDIDATES.flowerBookFlowerName), - name: pickColumn(schema.flowerColumns, FIELD_CANDIDATES.flowerBookName), - } - - const employeeRows = await queryReportRows( - pool, - sql, - buildEmployeeListSql( - schema.employeeColumns, - buildAccountScopedAliasWhere('e', { - dingtalkUserId: employeeColumns.dingtalkUserId, - email: employeeColumns.email, - names: [employeeColumns.name], - }, values), - ), - scopedLimit, - bindInputs, - ) - const registerRows = schema.registerColumns.size > 0 - ? await queryReportRows( - pool, - sql, - buildRegisterListSql( - schema.registerColumns, - buildAccountScopedAliasWhere('r', { - dingtalkUserId: registerColumns.dingtalkUserId, - email: registerColumns.email, - names: [registerColumns.flowerName, registerColumns.realName, registerColumns.registerName], - }, values), - ), - scopedLimit, - bindInputs, - ) - : [] - const flowerRows = schema.flowerColumns.size > 0 - ? await queryReportRows( - pool, - sql, - buildFlowerListSql( - schema.flowerColumns, - buildAccountScopedAliasWhere('f', { - names: [flowerColumns.flowerName, flowerColumns.name], - }, values), - ), - scopedLimit, - bindInputs, - ) - : [] - - const registerByUserId = indexRows(registerRows, ['dingtalkUserId']) - const registerByJobNumber = indexRows(registerRows, ['registerJobNumber', 'jobNumber']) - const registerByName = indexRows(registerRows, ['flowerName', 'realName', 'registerName']) - const flowerByJobNumber = indexRows(flowerRows, ['registerJobNumber', 'jobNumber']) - const flowerByName = indexRows(flowerRows, ['flowerBookFlowerName', 'flowerBookName']) - - const mergedRows: ReportEmployeeRow[] = [] - const seen = new Set() - const pushMerged = (row: ReportEmployeeRow) => { - const normalized = normalizeEmployeeRow(row) - const accountMatched = getNormalizedProfileMatchKeys(normalized) - .some((key) => identities.some((identity) => getIdentityMatchKeys(identity).includes(key))) - if (!accountMatched) return - - const key = normalized.dingtalkUserId || normalized.jobNumber || normalized.dingEmail || normalized.displayName - if (seen.has(key)) return - seen.add(key) - mergedRows.push(row) - } - - for (const employee of employeeRows) { - const register = getIndexed(registerByUserId, employee.dingtalkUserId) - || getIndexed(registerByJobNumber, employee.jobNumber) - || getIndexed(registerByName, employee.contactName) - const flower = getIndexed(flowerByJobNumber, register?.registerJobNumber, employee.jobNumber) - || getIndexed(flowerByName, register?.flowerName, register?.realName, register?.registerName, employee.contactName) - pushMerged(mergeReportRows(employee, register, flower)) - } - - for (const register of registerRows) { - const flower = getIndexed(flowerByJobNumber, register.registerJobNumber, register.jobNumber) - || getIndexed(flowerByName, register.flowerName, register.realName, register.registerName) - pushMerged(mergeReportRows(register, flower)) - } - - for (const flower of flowerRows) { - pushMerged(flower) - } - - return applyResolvedDepartmentPaths(pool, mergedRows.slice(0, scopedLimit)) -} - function buildIdentityWhere( employeeColumns: TableColumns, registerColumns: TableColumns, @@ -1441,6 +1254,30 @@ function getSourcePayloadLink(payload: unknown) { } } +function mergeExistingMaterialAccountLink(payload: unknown, existingPayload?: unknown) { + const merged = payload && typeof payload === 'object' && !Array.isArray(payload) + ? { ...(payload as Record) } + : {} + const incomingLink = getSourcePayloadLink(merged) + const existingLink = getSourcePayloadLink(existingPayload) + + if (!incomingLink.materialUserId && existingLink.materialUserId) { + merged.materialUserId = existingLink.materialUserId + } + if (!incomingLink.materialUserEmail && existingLink.materialUserEmail) { + merged.materialUserEmail = existingLink.materialUserEmail + } + + return merged +} + +function getNormalizedProfileSyncKey(profile: ReturnType) { + return clean(profile.dingtalkUserId)?.toLowerCase() + || clean(profile.jobNumber)?.toLowerCase() + || clean(profile.dingEmail)?.toLowerCase() + || `name:${clean(profile.displayName)?.toLowerCase() || 'unknown'}` +} + function previewValueEquals(left: unknown, right: unknown) { if (typeof left === 'boolean' || typeof right === 'boolean') { return Boolean(left) === Boolean(right) @@ -1620,7 +1457,13 @@ export function comparePerformanceSurveyEmployeeSyncPreview(input: { return } - const fields = hasBusinessProfileChanges(existing, proposed) + const fields = hasBusinessProfileChanges(existing, { + ...proposed, + sourcePayload: mergeExistingMaterialAccountLink( + proposed.sourcePayload, + existing.sourcePayload, + ), + }) if (fields.length === 0) { unchanged += 1 return @@ -1632,26 +1475,39 @@ export function comparePerformanceSurveyEmployeeSyncPreview(input: { } } + const materialIdentityByProfileKey = new Map() for (const [index, match] of Array.from(bestMatchesByIdentity.entries())) { const identity = identities[index] - const proposed: PreviewProfileData = { - ...match.normalized, - sourcePayload: { - ...(match.normalized.sourcePayload || {}), - materialUserId: identity.userId || null, - materialUserEmail: clean(identity.email), - }, + const profileKey = getNormalizedProfileSyncKey(match.normalized) + if (!materialIdentityByProfileKey.has(profileKey)) { + materialIdentityByProfileKey.set(profileKey, identity) } - const profileKey = getProposedProfileMatchKeys(proposed)[0] - || `name:${clean(proposed.displayName)?.toLowerCase() || index}` + matchedIdentityIndexes.add(index) + } + + for (const row of rows) { + const normalized = normalizeEmployeeRow(row) + if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) { + continue + } + const profileKey = getNormalizedProfileSyncKey(normalized) if (plannedProfileKeys.has(profileKey)) { deduplicated += 1 - matchedIdentityIndexes.add(index) continue } plannedProfileKeys.add(profileKey) - matchedIdentityIndexes.add(index) + const identity = materialIdentityByProfileKey.get(profileKey) + const proposed: PreviewProfileData = identity + ? { + ...normalized, + sourcePayload: { + ...(normalized.sourcePayload || {}), + materialUserId: identity.userId || null, + materialUserEmail: clean(identity.email), + }, + } + : normalized classify(proposed) } @@ -1785,15 +1641,22 @@ export async function upsertPerformanceSurveyEmployeeProfileForSync( return prisma.$transaction(async (tx) => { const existing = await resolveEmployeeProfileIdentityInTransaction(tx, data) const profileId = existing?.id || randomUUID() + const profileWriteData = { + ...profileData, + sourcePayload: mergeExistingMaterialAccountLink( + profileData.sourcePayload, + existing?.sourcePayload, + ) as any, + } const profile = existing ? await tx.performanceSurveyEmployeeProfile.update({ where: { id: existing.id }, - data: profileData, + data: profileWriteData, }) : await tx.performanceSurveyEmployeeProfile.create({ data: { id: profileId, - ...profileData, + ...profileWriteData, }, }) @@ -1829,11 +1692,10 @@ function getParentDepartmentName(departmentName?: string | null, departmentPath? } async function upsertDepartments() { - const accountWhere = await getPerformanceSurveyAccountProfileWhere() const employees = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ - accountWhere, + getActivePerformanceSurveyEmployeeProfileWhere(), { departmentName: { not: null } }, ], }, @@ -2153,7 +2015,7 @@ export async function previewPerformanceSurveyEmployeeSync(options?: { limit?: n const { sql, pool } = await getReportPool() try { const schema = await getReportSchema(pool) - rows = await fetchAccountScopedSyncRows(pool, sql, schema, identities, limit) + rows = await fetchFullSyncRows(pool, sql, schema, limit) } finally { await pool.close() } @@ -2170,13 +2032,16 @@ export async function previewPerformanceSurveyEmployeeSync(options?: { limit?: n return !latest || profile.syncedAt > latest ? profile.syncedAt : latest }, null) const warnings = [ - '这是只读预检:只读取素材库账号、本地人员档案和上游花名册,不会写入或停用任何人员。', + '这是只读预检:只读取素材库账号、本地人员档案和完整在职花名册,不会写入或停用任何人员。', '系统尚未记录“整批同步成功时间”;last.latestLocalProfileSyncedAt 仅表示最近一条本地人员档案的同步时间。', '现有正式同步不会把“本次上游未返回”直接视为离职或停用,因此停用人数无法可靠推断。', ] if (totalAccounts > identities.length) { - warnings.push(`本次按上限 ${limit} 预检了 ${identities.length}/${totalAccounts} 个素材库账号,结果不是全量。`) + warnings.push(`本次只读取了 ${identities.length}/${totalAccounts} 个素材库账号用于建立账号关联;花名册人员仍按上限独立读取。`) + } + if (configured && rows.length >= limit) { + warnings.push(`本次花名册读取达到上限 ${limit} 人,可能不是完整人员范围,请提高同步上限后重试。`) } if (!configured) { warnings.push('上游花名册数据库未配置;正式同步只会使用素材库账号建立或更新兜底档案。') @@ -2216,7 +2081,8 @@ export async function previewPerformanceSurveyEmployeeSync(options?: { limit?: n requestedLimit: limit, totalAccounts, scannedAccounts: identities.length, - truncated: totalAccounts > identities.length, + scannedProfiles: rows.length, + truncated: configured ? rows.length >= limit : totalAccounts > identities.length, previewStartedAt: startedAt.toISOString(), previewCompletedAt: completedAt.toISOString(), durationMs: completedAt.getTime() - startedAt.getTime(), @@ -2237,7 +2103,7 @@ export async function previewPerformanceSurveyEmployeeSync(options?: { limit?: n deactivation: { status: 'unsupported', count: null, - reason: '当前同步范围仅覆盖素材库现有账号,且正式同步没有“上游缺失即停用”的规则,无法可靠计算停用人数。', + reason: '正式同步不会把“本次上游未返回”直接视为离职或停用,无法仅根据一次读取可靠计算停用人数。', }, changedFields: comparison.changedFields, warnings, @@ -2256,6 +2122,7 @@ export async function syncPerformanceSurveyEmployees(options?: { limit?: number invalidatePerformanceSurveyAccountProfileWhereCache() return { scannedAccounts: identities.length, + scannedProfiles: 0, upserted: 0, fallback: identities.length, skipped: 0, @@ -2269,73 +2136,52 @@ export async function syncPerformanceSurveyEmployees(options?: { limit?: number try { const schema = await getReportSchema(pool) - const rows = await fetchAccountScopedSyncRows(pool, sql, schema, identities, limit) - const identityIndexesByKey = new Map() - identities.forEach((identity, index) => { - for (const key of getIdentityMatchKeys(identity)) { - const indexes = identityIndexesByKey.get(key) || [] - indexes.push(index) - identityIndexesByKey.set(key, indexes) - } - }) + const rows = await fetchFullSyncRows(pool, sql, schema, limit) + const { bestMatchesByIdentity } = matchNormalizedRowsToIdentities(identities, rows) let upserted = 0 let fallback = 0 let skipped = 0 let failed = 0 const matchedIdentityIndexes = new Set() - const bestMatchesByIdentity = new Map - score: number - }>() + const materialIdentityByProfileKey = new Map() + for (const [index, match] of Array.from(bestMatchesByIdentity.entries())) { + const profileKey = getNormalizedProfileSyncKey(match.normalized) + if (!materialIdentityByProfileKey.has(profileKey)) { + materialIdentityByProfileKey.set(profileKey, identities[index]) + } + matchedIdentityIndexes.add(index) + } + const syncedProfileKeys = new Set() for (const row of rows) { const normalized = normalizeEmployeeRow(row) if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) { skipped += 1 continue } - - const candidateIndexes = new Set() - for (const key of getNormalizedProfileMatchKeys(normalized)) { - for (const index of identityIndexesByKey.get(key) || []) { - candidateIndexes.add(index) - } - } - - for (const index of Array.from(candidateIndexes)) { - const score = getIdentityProfileMatchScore(identities[index], normalized) - if (score <= 0) continue - const current = bestMatchesByIdentity.get(index) - if (!current || score > current.score) { - bestMatchesByIdentity.set(index, { normalized, score }) - } - } - } - - const syncedProfileKeys = new Set() - for (const [index, match] of Array.from(bestMatchesByIdentity.entries())) { - const normalized = match.normalized - const identity = identities[index] - ;(normalized as any).sourcePayload = { - ...(normalized.sourcePayload || {}), - materialUserId: identity.userId || null, - materialUserEmail: clean(identity.email), - } - const profileKey = normalized.dingtalkUserId || normalized.jobNumber || normalized.dingEmail || normalized.displayName + const profileKey = getNormalizedProfileSyncKey(normalized) if (syncedProfileKeys.has(profileKey)) { - matchedIdentityIndexes.add(index) + skipped += 1 continue } + syncedProfileKeys.add(profileKey) + + const identity = materialIdentityByProfileKey.get(profileKey) + if (identity) { + ;(normalized as any).sourcePayload = { + ...(normalized.sourcePayload || {}), + materialUserId: identity.userId || null, + materialUserEmail: clean(identity.email), + } + } try { await upsertPerformanceSurveyEmployeeProfileForSync(normalized) upserted += 1 - syncedProfileKeys.add(profileKey) - matchedIdentityIndexes.add(index) } catch (error) { failed += 1 - console.warn('[performance-survey] 单个人员档案同步失败,使用素材库账号兜底:', { + console.warn('[performance-survey] 单个人员档案同步失败:', { dingtalkUserId: normalized.dingtalkUserId, dingEmail: normalized.dingEmail, error, @@ -2350,8 +2196,17 @@ export async function syncPerformanceSurveyEmployees(options?: { limit?: number skipped += 1 continue } - await upsertFallbackEmployeeProfileForUser(identity) - fallback += 1 + try { + await upsertFallbackEmployeeProfileForUser(identity) + fallback += 1 + } catch (error) { + failed += 1 + console.warn('[performance-survey] 素材库账号兜底档案同步失败:', { + dingtalkUserId: identity.dingtalkUserId, + email: identity.email, + error, + }) + } } const departmentCount = await upsertDepartments() @@ -2359,6 +2214,7 @@ export async function syncPerformanceSurveyEmployees(options?: { limit?: number return { scannedAccounts: identities.length, + scannedProfiles: rows.length, upserted, fallback, skipped,