From 7b4d70360d7c52db35ed3f7dc3bd274cece9345f Mon Sep 17 00:00:00 2001 From: chenpei Date: Fri, 3 Jul 2026 18:09:59 +0800 Subject: [PATCH] fix: scope performance survey roster sync --- app/performance-survey/[[...path]]/page.tsx | 10 +- lib/performance-survey-report-sync.ts | 354 +++++++++++++++----- lib/performance-survey-service.ts | 253 +++++++------- 3 files changed, 410 insertions(+), 207 deletions(-) diff --git a/app/performance-survey/[[...path]]/page.tsx b/app/performance-survey/[[...path]]/page.tsx index 71dfc4f9..d7ad4a18 100644 --- a/app/performance-survey/[[...path]]/page.tsx +++ b/app/performance-survey/[[...path]]/page.tsx @@ -38,6 +38,7 @@ type DepartmentOption = { value?: string | null departmentName?: string | null departmentPath?: string | null + fullPath?: string | null } type Template = { @@ -638,6 +639,7 @@ function DepartmentPicker({ department.parentName, department.departmentName, department.departmentPath, + department.fullPath, ].filter(Boolean).join(' ').toLowerCase() if (!haystack.includes(normalizedKeyword)) return @@ -761,9 +763,11 @@ function DepartmentPicker({ onClick={() => department.value && toggleValue(department.value, false)} className="rounded-md bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200" > - {department.parentName && department.parentName !== '全部部门' - ? `${department.parentName} / ${department.name}` - : department.name} × + {department.fullPath || department.departmentPath || ( + department.parentName && department.parentName !== '全部部门' + ? `${department.parentName} / ${department.name}` + : department.name + )} × ))} diff --git a/lib/performance-survey-report-sync.ts b/lib/performance-survey-report-sync.ts index 7f731ea6..e89f48d8 100644 --- a/lib/performance-survey-report-sync.ts +++ b/lib/performance-survey-report-sync.ts @@ -6,6 +6,7 @@ type ReportEmployeeRow = { dingtalkUserId?: string | null dingEmail?: string | null jobNumber?: string | null + employeeDeptIdList?: string | null contactName?: string | null contactTitle?: string | null workPlace?: string | null @@ -18,6 +19,8 @@ type ReportEmployeeRow = { positionLevel?: string | null departmentName?: string | null departmentPath?: string | null + registerDeptIds?: string | null + registerMainDeptId?: string | null employmentStatus?: string | null managerDingtalkUserId?: string | null managerJobNumber?: string | null @@ -25,6 +28,7 @@ type ReportEmployeeRow = { flowerBookName?: string | null flowerBookFlowerName?: string | null flowerBookDept?: string | null + flowerBookDeptId?: string | null flowerBookWorkPlace?: string | null flowerBookJob?: string | null flowerBookJobLevel?: string | null @@ -41,6 +45,8 @@ type ReportIdentity = { realName?: string | null displayName?: string | null flowerName?: string | null + userId?: string | null + department?: string | null } type TableColumns = Map @@ -69,6 +75,7 @@ const FIELD_CANDIDATES = { workPlace: ['work_place', 'workPlace', 'workplace', 'office_location', '办公地点'], extension: ['extension', 'ext', 'extattr', 'ext_attr'], active: ['active', 'is_active', 'enabled'], + employeeDeptIdList: ['dept_id_list', 'deptIdList', 'department_id_list', '部门id列表'], registerName: ['sys00-name', 'name', '姓名'], flowerName: ['sys00-flowerName', 'flower_name', 'flowerName', '花名'], registerRealName: ['sys02-realName', 'real_name', 'realName', '真实姓名'], @@ -77,6 +84,8 @@ const FIELD_CANDIDATES = { positionLevel: ['sys01-positionLevel', 'position_level', 'positionLevel', '岗位职级', '职级'], departmentName: ['sys00-dept', 'department', 'department_name', 'dept_name', '部门'], departmentPath: ['sys00-mainDepts', 'department_path', 'main_departments', '部门路径'], + registerDeptIds: ['sys00-deptIds', 'dept_ids', 'department_ids', '部门ids'], + registerMainDeptId: ['sys00-mainDeptId', 'main_dept_id', 'mainDepartmentId', '主部门id'], employmentStatus: ['sys01-employeeStatus', 'employee_status', 'employment_status', '在职状态'], managerDingtalkUserId: ['manager_userid', 'managerUserid', 'leader_userid', '主管userid'], managerJobNumber: ['manager_job_number', 'managerJobNumber', 'leader_job_number', '主管工号'], @@ -85,6 +94,7 @@ const FIELD_CANDIDATES = { flowerBookName: ['name', '姓名'], flowerBookFlowerName: ['flowerName', 'flower_name', '花名'], flowerBookDept: ['dept', 'department', 'department_name', '部门'], + flowerBookDeptId: ['deptId', 'dept_id', 'department_id', '部门id'], flowerBookWorkPlace: ['workPlace', 'work_place', '办公地点'], flowerBookJob: ['job', 'position', '岗位'], flowerBookJobLevel: ['jobLevel', 'job_level', '岗位职级', '职级'], @@ -155,21 +165,55 @@ function parseExtension(value?: string | null) { } function parseDepartmentValue(value?: string | null) { + const parts = parseDepartmentParts(value) + if (parts.length > 0) return parts[parts.length - 1] || parts[0] || null + const normalized = clean(value) if (!normalized) return null + return normalized +} + +function parseDepartmentParts(value?: string | null) { + const normalized = clean(value) + if (!normalized) return [] + if (normalized.startsWith('[')) { try { const parsed = JSON.parse(normalized) if (Array.isArray(parsed)) { - return clean(parsed[parsed.length - 1]) || clean(parsed[0]) + return parsed.map((item) => clean(item)).filter((item): item is string => Boolean(item)) } } catch { - return normalized + // 继续按普通字符串拆分,兼容报表库里偶发的非标准 JSON 文本。 } } return normalized + .split(/[/>||,,;;]/) + .map((item) => item.trim()) + .filter(Boolean) +} + +function getDepartmentParts(row: ReportEmployeeRow) { + const flowerBookParts = parseDepartmentParts(row.flowerBookDept) + if (flowerBookParts.length > 0) return flowerBookParts + + const pathParts = parseDepartmentParts(row.departmentPath) + const nameParts = parseDepartmentParts(row.departmentName) + if (pathParts.length === 0) return nameParts + if (nameParts.length === 0) return pathParts + + const missingNameParts = nameParts.filter((part) => !pathParts.includes(part)) + return missingNameParts.length > 0 ? [...missingNameParts, ...pathParts] : pathParts +} + +function joinDepartmentParts(parts: string[]) { + return parts.length > 0 ? parts.join('/') : null +} + +function getDepartmentLeaf(parts: string[]) { + return parts.length > 0 ? parts[parts.length - 1] || parts[0] || null : null } function getLevelType(positionLevel?: string | null) { @@ -266,6 +310,7 @@ function buildSelectSql( workPlace: pickColumn(employeeColumns, FIELD_CANDIDATES.workPlace), extension: pickColumn(employeeColumns, FIELD_CANDIDATES.extension), active: pickColumn(employeeColumns, FIELD_CANDIDATES.active), + deptIdList: pickColumn(employeeColumns, FIELD_CANDIDATES.employeeDeptIdList), managerDingtalkUserId: pickColumn(employeeColumns, FIELD_CANDIDATES.managerDingtalkUserId), managerJobNumber: pickColumn(employeeColumns, FIELD_CANDIDATES.managerJobNumber), managerName: pickColumn(employeeColumns, FIELD_CANDIDATES.managerName), @@ -281,6 +326,8 @@ function buildSelectSql( positionLevel: pickColumn(registerColumns, FIELD_CANDIDATES.positionLevel), departmentName: pickColumn(registerColumns, FIELD_CANDIDATES.departmentName), departmentPath: pickColumn(registerColumns, FIELD_CANDIDATES.departmentPath), + deptIds: pickColumn(registerColumns, FIELD_CANDIDATES.registerDeptIds), + mainDeptId: pickColumn(registerColumns, FIELD_CANDIDATES.registerMainDeptId), employmentStatus: pickColumn(registerColumns, FIELD_CANDIDATES.employmentStatus), managerDingtalkUserId: pickColumn(registerColumns, FIELD_CANDIDATES.managerDingtalkUserId), managerJobNumber: pickColumn(registerColumns, FIELD_CANDIDATES.managerJobNumber), @@ -291,6 +338,7 @@ function buildSelectSql( name: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookName), flowerName: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookFlowerName), dept: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDept), + deptId: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDeptId), workPlace: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookWorkPlace), job: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJob), jobLevel: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJobLevel), @@ -321,6 +369,7 @@ function buildSelectSql( ${columnExpr('e', e.title)} AS "contactTitle", ${columnExpr('e', e.workPlace)} AS "workPlace", ${columnExpr('e', e.extension)} AS "extension", + ${columnExpr('e', e.deptIdList)} AS "employeeDeptIdList", ${columnExpr('r', r.registerName)} AS "registerName", ${columnExpr('r', r.flowerName)} AS "flowerName", ${columnExpr('r', r.realName)} AS "realName", @@ -329,6 +378,8 @@ function buildSelectSql( ${columnExpr('r', r.positionLevel)} AS "positionLevel", ${columnExpr('r', r.departmentName)} AS "departmentName", ${columnExpr('r', r.departmentPath)} AS "departmentPath", + ${columnExpr('r', r.deptIds)} AS "registerDeptIds", + ${columnExpr('r', r.mainDeptId)} AS "registerMainDeptId", ${columnExpr('r', r.employmentStatus)} AS "employmentStatus", COALESCE(${columnExpr('r', r.managerDingtalkUserId)}, ${columnExpr('e', e.managerDingtalkUserId)}) AS "managerDingtalkUserId", COALESCE(${columnExpr('r', r.managerJobNumber)}, ${columnExpr('e', e.managerJobNumber)}) AS "managerJobNumber", @@ -336,6 +387,7 @@ function buildSelectSql( ${columnExpr('f', f.name)} AS "flowerBookName", ${columnExpr('f', f.flowerName)} AS "flowerBookFlowerName", ${columnExpr('f', f.dept)} AS "flowerBookDept", + ${columnExpr('f', f.deptId)} AS "flowerBookDeptId", ${columnExpr('f', f.workPlace)} AS "flowerBookWorkPlace", ${columnExpr('f', f.job)} AS "flowerBookJob", ${columnExpr('f', f.jobLevel)} AS "flowerBookJobLevel", @@ -362,6 +414,7 @@ function buildEmployeeListSql(employeeColumns: TableColumns) { workPlace: pickColumn(employeeColumns, FIELD_CANDIDATES.workPlace), extension: pickColumn(employeeColumns, FIELD_CANDIDATES.extension), active: pickColumn(employeeColumns, FIELD_CANDIDATES.active), + deptIdList: pickColumn(employeeColumns, FIELD_CANDIDATES.employeeDeptIdList), managerDingtalkUserId: pickColumn(employeeColumns, FIELD_CANDIDATES.managerDingtalkUserId), managerJobNumber: pickColumn(employeeColumns, FIELD_CANDIDATES.managerJobNumber), managerName: pickColumn(employeeColumns, FIELD_CANDIDATES.managerName), @@ -377,6 +430,7 @@ function buildEmployeeListSql(employeeColumns: TableColumns) { ${columnExpr('e', e.title)} AS "contactTitle", ${columnExpr('e', e.workPlace)} AS "workPlace", ${columnExpr('e', e.extension)} AS "extension", + ${columnExpr('e', e.deptIdList)} AS "employeeDeptIdList", ${columnExpr('e', e.managerDingtalkUserId)} AS "managerDingtalkUserId", ${columnExpr('e', e.managerJobNumber)} AS "managerJobNumber", ${columnExpr('e', e.managerName)} AS "managerName" @@ -398,6 +452,8 @@ function buildRegisterListSql(registerColumns: TableColumns) { positionLevel: pickColumn(registerColumns, FIELD_CANDIDATES.positionLevel), departmentName: pickColumn(registerColumns, FIELD_CANDIDATES.departmentName), departmentPath: pickColumn(registerColumns, FIELD_CANDIDATES.departmentPath), + deptIds: pickColumn(registerColumns, FIELD_CANDIDATES.registerDeptIds), + mainDeptId: pickColumn(registerColumns, FIELD_CANDIDATES.registerMainDeptId), employmentStatus: pickColumn(registerColumns, FIELD_CANDIDATES.employmentStatus), managerDingtalkUserId: pickColumn(registerColumns, FIELD_CANDIDATES.managerDingtalkUserId), managerJobNumber: pickColumn(registerColumns, FIELD_CANDIDATES.managerJobNumber), @@ -416,6 +472,8 @@ function buildRegisterListSql(registerColumns: TableColumns) { ${columnExpr('r', r.positionLevel)} AS "positionLevel", ${columnExpr('r', r.departmentName)} AS "departmentName", ${columnExpr('r', r.departmentPath)} AS "departmentPath", + ${columnExpr('r', r.deptIds)} AS "registerDeptIds", + ${columnExpr('r', r.mainDeptId)} AS "registerMainDeptId", ${columnExpr('r', r.employmentStatus)} AS "employmentStatus", ${columnExpr('r', r.managerDingtalkUserId)} AS "managerDingtalkUserId", ${columnExpr('r', r.managerJobNumber)} AS "managerJobNumber", @@ -431,6 +489,7 @@ function buildFlowerListSql(flowerColumns: TableColumns) { name: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookName), flowerName: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookFlowerName), dept: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDept), + deptId: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDeptId), workPlace: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookWorkPlace), job: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJob), jobLevel: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJobLevel), @@ -446,6 +505,7 @@ function buildFlowerListSql(flowerColumns: TableColumns) { ${columnExpr('f', f.name)} AS "flowerBookName", ${columnExpr('f', f.flowerName)} AS "flowerBookFlowerName", ${columnExpr('f', f.dept)} AS "flowerBookDept", + ${columnExpr('f', f.deptId)} AS "flowerBookDeptId", ${columnExpr('f', f.workPlace)} AS "flowerBookWorkPlace", ${columnExpr('f', f.job)} AS "flowerBookJob", ${columnExpr('f', f.jobLevel)} AS "flowerBookJobLevel", @@ -479,6 +539,70 @@ function indexRows(rows: ReportEmployeeRow[], keys: Array ({ + userId: user.id, + email: user.email, + username: user.username, + realName: user.realName, + displayName: clean(user.realName) || clean(user.username), + flowerName: clean(user.realName) || clean(user.username), + dingtalkUserId: user.dingtalkUserId, + department: user.department, + })) +} + +export async function getPerformanceSurveyAccountProfileWhere() { + const identities = await listMaterialAccountSyncIdentities() + const dingtalkUserIds = Array.from(new Set(identities.map((item) => clean(item.dingtalkUserId)).filter(Boolean))) as string[] + const emails = Array.from(new Set(identities.map((item) => clean(item.email)).filter(Boolean))) as string[] + const names = Array.from(new Set( + identities + .flatMap((item) => [item.flowerName, item.displayName, item.realName, item.username]) + .map((item) => clean(item)) + .filter(Boolean), + )) as string[] + + const or: any[] = [] + if (dingtalkUserIds.length > 0) { + or.push({ dingtalkUserId: { in: dingtalkUserIds } }) + } + if (emails.length > 0) { + or.push({ dingEmail: { in: emails } }) + } + if (names.length > 0) { + or.push({ displayName: { in: names } }) + or.push({ flowerName: { in: names } }) + or.push({ realName: { in: names } }) + } + + return or.length > 0 ? { OR: or } : { id: { in: [] } } +} + function getIndexed(index: Map, ...keys: unknown[]) { for (const key of keys) { const normalized = normalizedKey(key) @@ -630,7 +754,9 @@ function normalizeEmployeeRow(row: ReportEmployeeRow) { const displayName = flowerName || realName || jobNumber || dingtalkUserId || dingEmail || '未命名员工' const positionLevel = clean(row.flowerBookJobLevel) || clean(row.positionLevel) || clean(extension['岗位职级']) const levelType = getLevelType(positionLevel) - const departmentName = parseDepartmentValue(row.flowerBookDept) || clean(row.departmentName) + const departmentParts = getDepartmentParts(row) + const departmentName = getDepartmentLeaf(departmentParts) || parseDepartmentValue(row.flowerBookDept) || clean(row.departmentName) + const departmentPath = joinDepartmentParts(departmentParts) || clean(row.departmentPath) const positionTitle = clean(row.flowerBookJob) || clean(row.positionTitle) || clean(row.contactTitle) const managerName = clean(row.flowerBookManager) || clean(row.flowerBookDirector) || clean(row.managerName) || clean(extension['主管']) const employmentStatus = clean(row.flowerBookEmployeeStatus) || clean(row.employmentStatus) @@ -644,7 +770,7 @@ function normalizeEmployeeRow(row: ReportEmployeeRow) { realName, displayName, departmentName, - departmentPath: clean(row.departmentPath), + departmentPath, positionTitle, positionLevel, levelType, @@ -662,6 +788,11 @@ function normalizeEmployeeRow(row: ReportEmployeeRow) { jobNumber, displayName, departmentName, + departmentPath, + employeeDeptIdList: clean(row.employeeDeptIdList), + registerDeptIds: clean(row.registerDeptIds), + registerMainDeptId: clean(row.registerMainDeptId), + flowerBookDeptId: clean(row.flowerBookDeptId), positionLevel, levelType, flowerBookRole: clean(row.flowerBookRole), @@ -702,19 +833,20 @@ async function upsertEmployeeProfile(data: ReturnType||,,;;]/) - .map((item) => item.trim()) - .filter(Boolean) + const parts = parseDepartmentParts(departmentPath) if (!current || parts.length < 2) return null const currentIndex = parts.lastIndexOf(current) return currentIndex > 0 ? parts[currentIndex - 1] : parts[parts.length - 2] || null } async function upsertDepartments() { + const accountWhere = await getPerformanceSurveyAccountProfileWhere() const employees = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { - departmentName: { not: null }, + AND: [ + accountWhere, + { departmentName: { not: null } }, + ], }, select: { departmentName: true, @@ -770,41 +902,98 @@ async function getReportPool() { } } +async function fetchNormalizedEmployeeByIdentityWithContext( + pool: any, + sql: typeof import('mssql'), + schema: Awaited>, + identity: ReportIdentity, +) { + const where = buildIdentityWhere( + schema.employeeColumns, + schema.registerColumns, + schema.flowerColumns, + identity, + ) + if (where.whereSql === '1 = 0') return null + + const request = pool.request().input('limit', sql.Int, 1) + if (clean(identity.dingtalkUserId)) request.input('dingtalkUserId', sql.NVarChar(200), clean(identity.dingtalkUserId)) + if (clean(identity.email)) request.input('email', sql.NVarChar(320), clean(identity.email)) + if (where.nameParam) request.input('name', sql.NVarChar(200), where.nameParam) + + const result = await request.query( + buildSelectSql( + schema.employeeColumns, + schema.registerColumns, + schema.flowerColumns, + where.whereSql, + '@limit', + ), + ) + const row = result.recordset?.[0] as ReportEmployeeRow | undefined + if (!row) return null + + const normalized = normalizeEmployeeRow(row) + ;(normalized as any).sourcePayload = { + ...(normalized.sourcePayload || {}), + materialUserId: identity.userId || null, + materialUserEmail: clean(identity.email), + } + return normalized +} + +async function syncPerformanceSurveyEmployeeByIdentityWithContext( + pool: any, + sql: typeof import('mssql'), + schema: Awaited>, + identity: ReportIdentity, +) { + const normalized = await fetchNormalizedEmployeeByIdentityWithContext(pool, sql, schema, identity) + if (!normalized) return null + if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) return null + return upsertEmployeeProfile(normalized) +} + +async function upsertFallbackEmployeeProfileForUser(identity: ReportIdentity & { userId?: string }) { + const existing = await findExistingEmployeeProfile({ + dingtalkUserId: clean(identity.dingtalkUserId), + dingEmail: clean(identity.email), + }) + const data = { + dingtalkUserId: clean(identity.dingtalkUserId), + dingEmail: clean(identity.email), + displayName: clean(identity.flowerName) || clean(identity.displayName) || clean(identity.realName) || clean(identity.username) || '未知员工', + realName: clean(identity.realName) || clean(identity.username), + flowerName: clean(identity.flowerName) || null, + departmentName: clean(identity.department), + departmentPath: clean(identity.department), + sourcePayload: { source: 'material_user_fallback', userId: identity.userId || null }, + syncedAt: new Date(), + } + + if (existing) { + return prisma.performanceSurveyEmployeeProfile.update({ + where: { id: existing.id }, + data, + }) + } + + return prisma.performanceSurveyEmployeeProfile.create({ + data: { + id: randomUUID(), + ...data, + }, + }) +} + export async function syncPerformanceSurveyEmployeeByIdentity(identity: ReportIdentity) { if (!isPerformanceSurveyReportDbConfigured()) return null const { sql, pool } = await getReportPool() try { const schema = await getReportSchema(pool) - const where = buildIdentityWhere( - schema.employeeColumns, - schema.registerColumns, - schema.flowerColumns, - identity, - ) - if (where.whereSql === '1 = 0') return null - - const request = pool.request().input('limit', sql.Int, 1) - if (clean(identity.dingtalkUserId)) request.input('dingtalkUserId', sql.NVarChar(200), clean(identity.dingtalkUserId)) - if (clean(identity.email)) request.input('email', sql.NVarChar(320), clean(identity.email)) - if (where.nameParam) request.input('name', sql.NVarChar(200), where.nameParam) - - const result = await request.query( - buildSelectSql( - schema.employeeColumns, - schema.registerColumns, - schema.flowerColumns, - where.whereSql, - '@limit', - ), - ) - const row = result.recordset?.[0] - if (!row) return null - - const normalized = normalizeEmployeeRow(row) - if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) return null - const profile = await upsertEmployeeProfile(normalized) - await upsertDepartments() + const profile = await syncPerformanceSurveyEmployeeByIdentityWithContext(pool, sql, schema, identity) + if (profile) await upsertDepartments() return profile } finally { await pool.close() @@ -817,30 +1006,7 @@ export async function previewPerformanceSurveyEmployeeByIdentity(identity: Repor const { sql, pool } = await getReportPool() try { const schema = await getReportSchema(pool) - const where = buildIdentityWhere( - schema.employeeColumns, - schema.registerColumns, - schema.flowerColumns, - identity, - ) - if (where.whereSql === '1 = 0') return null - - const request = pool.request().input('limit', sql.Int, 1) - if (clean(identity.dingtalkUserId)) request.input('dingtalkUserId', sql.NVarChar(200), clean(identity.dingtalkUserId)) - if (clean(identity.email)) request.input('email', sql.NVarChar(320), clean(identity.email)) - if (where.nameParam) request.input('name', sql.NVarChar(200), where.nameParam) - - const result = await request.query( - buildSelectSql( - schema.employeeColumns, - schema.registerColumns, - schema.flowerColumns, - where.whereSql, - '@limit', - ), - ) - const row = result.recordset?.[0] - return row ? normalizeEmployeeRow(row) : null + return fetchNormalizedEmployeeByIdentityWithContext(pool, sql, schema, identity) } finally { await pool.close() } @@ -862,19 +1028,7 @@ export async function ensurePerformanceSurveyEmployeeProfileForUser(identity: Re } if (existing) return existing - - const fallbackData = { - id: randomUUID(), - dingtalkUserId: clean(identity.dingtalkUserId), - dingEmail: clean(identity.email), - displayName: clean(identity.flowerName) || clean(identity.displayName) || clean(identity.realName) || clean(identity.username) || '未知员工', - realName: clean(identity.realName) || clean(identity.username), - flowerName: clean(identity.flowerName) || null, - sourcePayload: { source: 'material_user_fallback', userId: identity.userId || null }, - syncedAt: new Date(), - } - - return prisma.performanceSurveyEmployeeProfile.create({ data: fallbackData }) + return upsertFallbackEmployeeProfileForUser(identity) } export function ensurePerformanceSurveyDailyEmployeeSyncStarted() { @@ -904,29 +1058,67 @@ export function ensurePerformanceSurveyDailyEmployeeSyncStarted() { export async function syncPerformanceSurveyEmployees(options?: { limit?: number }) { const limit = Math.max(1, Math.min(Number(options?.limit || 3000), 10_000)) + const identities = await listMaterialAccountSyncIdentities(limit) + + if (!isPerformanceSurveyReportDbConfigured()) { + for (const identity of identities) { + await upsertFallbackEmployeeProfileForUser(identity) + } + const departmentCount = await upsertDepartments() + return { + scannedAccounts: identities.length, + upserted: 0, + fallback: identities.length, + skipped: 0, + failed: 0, + departmentCount, + syncedAt: new Date().toISOString(), + } + } + const { sql, pool } = await getReportPool() try { const schema = await getReportSchema(pool) - const rows = await fetchFullSyncRows(pool, sql, schema, limit) let upserted = 0 + let fallback = 0 let skipped = 0 - for (const row of rows) { - const normalized = normalizeEmployeeRow(row) - if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) { + let failed = 0 + for (const identity of identities) { + if (!clean(identity.dingtalkUserId) && !clean(identity.email) && !clean(identity.username) && !clean(identity.realName)) { skipped += 1 continue } - await upsertEmployeeProfile(normalized) - upserted += 1 + + try { + const synced = await syncPerformanceSurveyEmployeeByIdentityWithContext(pool, sql, schema, identity) + if (synced) { + upserted += 1 + } else { + await upsertFallbackEmployeeProfileForUser(identity) + fallback += 1 + } + } catch (error) { + failed += 1 + console.warn('[performance-survey] 单个人员档案同步失败,使用素材库账号兜底:', { + userId: identity.userId, + email: identity.email, + error, + }) + await upsertFallbackEmployeeProfileForUser(identity) + fallback += 1 + } } const departmentCount = await upsertDepartments() return { + scannedAccounts: identities.length, upserted, + fallback, skipped, + failed, departmentCount, syncedAt: new Date().toISOString(), } diff --git a/lib/performance-survey-service.ts b/lib/performance-survey-service.ts index b4eb6432..b5f49860 100644 --- a/lib/performance-survey-service.ts +++ b/lib/performance-survey-service.ts @@ -20,6 +20,7 @@ import { import { ensurePerformanceSurveyDailyEmployeeSyncStarted, ensurePerformanceSurveyEmployeeProfileForUser, + getPerformanceSurveyAccountProfileWhere, syncPerformanceSurveyEmployees, } from '@/lib/performance-survey-report-sync' @@ -78,26 +79,64 @@ function decodeDepartmentPart(value: string) { } } -function getDepartmentPathValue(departmentName: string, departmentPath: string) { - return `${DEPARTMENT_PATH_VALUE_PREFIX}${encodeDepartmentPart(departmentName)}|${encodeDepartmentPart(departmentPath)}` +function parseDepartmentPathParts(value?: string | null) { + const normalized = clean(value) + if (!normalized) return [] + if (normalized.startsWith('[')) { + try { + const parsed = JSON.parse(normalized) + if (Array.isArray(parsed)) { + return parsed.map((item) => clean(item)).filter((item): item is string => Boolean(item)) + } + } catch { + // 继续按普通分隔符拆分。 + } + } + return normalized + .split(/[/>||,,;;]/) + .map((item) => item.trim()) + .filter(Boolean) } -function getDepartmentNodeId(departmentName: string) { - return `dept:${encodeDepartmentPart(departmentName)}` +function getDepartmentPathValue(departmentPath: string) { + return `${DEPARTMENT_PATH_VALUE_PREFIX}${encodeDepartmentPart(departmentPath)}` } -function getDepartmentPathNodeId(departmentName: string, departmentPath: string) { - return `deptPath:${encodeDepartmentPart(departmentName)}|${encodeDepartmentPart(departmentPath)}` +function getDepartmentNodeId(departmentPath: string) { + return `dept:${encodeDepartmentPart(departmentPath)}` +} + +function getScopeDepartmentWhere(scopeDepartment: string) { + return { + OR: [ + { departmentName: scopeDepartment }, + { departmentPath: scopeDepartment }, + { departmentPath: { startsWith: `${scopeDepartment}/` } }, + { departmentPath: { contains: `/${scopeDepartment}/` } }, + { departmentPath: { endsWith: `/${scopeDepartment}` } }, + ], + } } function parseDepartmentSelection(value: string) { if (value.startsWith(DEPARTMENT_PATH_VALUE_PREFIX)) { const payload = value.slice(DEPARTMENT_PATH_VALUE_PREFIX.length) - const [departmentNameRaw, departmentPathRaw] = payload.split('|') - const departmentName = clean(decodeDepartmentPart(departmentNameRaw || '')) - const departmentPath = clean(decodeDepartmentPart(departmentPathRaw || '')) - if (departmentName && departmentPath) { - return { departmentName, departmentPath } + const decoded = decodeDepartmentPart(payload) + if (decoded.includes('|')) { + const [departmentNameRaw, departmentPathRaw] = decoded.split('|') + const departmentName = clean(departmentNameRaw) + const departmentPath = clean(departmentPathRaw) + if (departmentName && departmentPath) { + return { departmentName, departmentPath } + } + } + const departmentPath = clean(decoded) + if (departmentPath) { + const parts = parseDepartmentPathParts(departmentPath) + return { + departmentName: parts[parts.length - 1] || departmentPath, + departmentPath, + } } } @@ -115,9 +154,10 @@ function getDepartmentSelectionWhere(values: string[]) { if (parsed.departmentPath) { departmentPathClauses.push({ - AND: [ - { departmentName: parsed.departmentName }, + OR: [ { departmentPath: parsed.departmentPath }, + { departmentPath: { startsWith: `${parsed.departmentPath}/` } }, + ...(parsed.departmentPath.includes('/') ? [] : [{ departmentName: parsed.departmentName }]), ], }) } else { @@ -306,7 +346,8 @@ export async function searchPerformanceSurveyEmployees(session: Session | null, const department = clean(params.get('department')) const limit = Math.max(1, Math.min(Number(params.get('limit') || 50), 200)) - const and: any[] = [] + const accountWhere = await getPerformanceSurveyAccountProfileWhere() + const and: any[] = [accountWhere] if (department) { const departmentClauses = getDepartmentSelectionWhere([department]) if (departmentClauses.length === 1) { @@ -316,7 +357,7 @@ export async function searchPerformanceSurveyEmployees(session: Session | null, } } if (!ctx.isSystemAdmin && ctx.scopeDepartment) { - and.push({ departmentName: ctx.scopeDepartment }) + and.push(getScopeDepartmentWhere(ctx.scopeDepartment)) } if (search) { and.push({ OR: [ @@ -332,7 +373,7 @@ export async function searchPerformanceSurveyEmployees(session: Session | null, { managerName: { contains: search, mode: 'insensitive' } }, ] }) } - const where: any = and.length > 0 ? { AND: and } : {} + const where: any = { AND: and } const employees = await prisma.performanceSurveyEmployeeProfile.findMany({ where, @@ -347,120 +388,73 @@ export async function listPerformanceSurveyDepartments(session: Session | null) const ctx = await getPerformanceSurveyContext(session) ensureManager(ctx) - const where: any = - !ctx.isSystemAdmin && ctx.scopeDepartment - ? { departmentName: ctx.scopeDepartment } - : { departmentName: { not: null } } + const accountWhere = await getPerformanceSurveyAccountProfileWhere() + const and: any[] = [ + accountWhere, + { departmentName: { not: null } }, + ] + if (!ctx.isSystemAdmin && ctx.scopeDepartment) { + and.push(getScopeDepartmentWhere(ctx.scopeDepartment)) + } const employees = await prisma.performanceSurveyEmployeeProfile.findMany({ - where, + where: { AND: and }, select: { departmentName: true, departmentPath: true, }, }) - if (employees.length > 0) { - const departments = new Map - }>() + 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 - for (const employee of employees) { - const departmentName = clean(employee.departmentName) - if (!departmentName) continue - - const existing = departments.get(departmentName) || { - name: departmentName, - employeeCount: 0, - children: new Map(), - } - existing.employeeCount += 1 - - const departmentPath = clean(employee.departmentPath) - if (departmentPath && departmentPath !== departmentName) { - existing.children.set(departmentPath, (existing.children.get(departmentPath) || 0) + 1) + 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 } - departments.set(departmentName, existing) + 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, + }) } + } - const sortedDepartments = Array.from(departments.values()).sort((a, b) => { - if (b.employeeCount !== a.employeeCount) return b.employeeCount - a.employeeCount - return a.name.localeCompare(b.name, 'zh-CN') - }) - - const totalEmployees = sortedDepartments.reduce((sum, item) => sum + item.employeeCount, 0) - const nodes: any[] = [{ + return [ + { id: ALL_DEPARTMENTS_NODE_ID, name: ALL_DEPARTMENTS_NODE_NAME, parentId: null, parentName: null, - employeeCount: totalEmployees, + employeeCount: employees.length, selectable: false, value: null, - }] - - for (const department of sortedDepartments) { - const departmentId = getDepartmentNodeId(department.name) - nodes.push({ - id: departmentId, - name: department.name, - parentId: ALL_DEPARTMENTS_NODE_ID, - parentName: ALL_DEPARTMENTS_NODE_NAME, - employeeCount: department.employeeCount, - selectable: true, - value: department.name, - }) - - const children = Array.from(department.children.entries()).sort((a, b) => { - if (b[1] !== a[1]) return b[1] - a[1] - return a[0].localeCompare(b[0], 'zh-CN') - }) - for (const [departmentPath, employeeCount] of children) { - nodes.push({ - id: getDepartmentPathNodeId(department.name, departmentPath), - name: departmentPath, - parentId: departmentId, - parentName: department.name, - employeeCount, - selectable: true, - value: getDepartmentPathValue(department.name, departmentPath), - departmentName: department.name, - departmentPath, - }) - } - } - - return nodes - } - - const grouped = await prisma.performanceSurveyEmployeeProfile.groupBy({ - by: ['departmentName'], - where: { - departmentName: ctx.isSystemAdmin - ? { not: null } - : ctx.scopeDepartment - ? ctx.scopeDepartment - : { not: null }, - } as any, - _count: { _all: true }, - orderBy: { departmentName: 'asc' }, - }) - - return grouped - .filter((item) => item.departmentName) - .map((item) => ({ - id: item.departmentName!, - name: item.departmentName!, - parentName: null, - parentId: null, - employeeCount: item._count._all, - syncedAt: null, - selectable: true, - value: item.departmentName!, - })) + departmentPath: null, + fullPath: null, + }, + ...Array.from(nodeByPath.values()).sort((a, b) => { + const depthDiff = parseDepartmentPathParts(a.departmentPath).length - parseDepartmentPathParts(b.departmentPath).length + if (depthDiff !== 0) return depthDiff + if (b.employeeCount !== a.employeeCount) return b.employeeCount - a.employeeCount + return a.fullPath.localeCompare(b.fullPath, 'zh-CN') + }), + ] } export async function listPerformanceSurveys(session: Session | null) { @@ -1093,7 +1087,12 @@ async function resolveSurveyTargets(body: any, surveyKind: string) { const employeeTargets = targetEmployeeIds.length > 0 ? await prisma.performanceSurveyEmployeeProfile.findMany({ - where: { id: { in: targetEmployeeIds } }, + where: { + AND: [ + await getPerformanceSurveyAccountProfileWhere(), + { id: { in: targetEmployeeIds } }, + ], + }, }) : [] @@ -1130,31 +1129,39 @@ async function resolveSurveyEvaluators(body: any, ctx: UserContext) { or.push({ id: ctx.employee.id }) } - const where: any = { OR: or } + const and: any[] = [ + await getPerformanceSurveyAccountProfileWhere(), + { OR: or }, + ] if (!ctx.isSystemAdmin && ctx.scopeDepartment) { - where.departmentName = ctx.scopeDepartment + and.push(getScopeDepartmentWhere(ctx.scopeDepartment)) } + const where: any = { AND: and } const employees = or.length > 0 ? await prisma.performanceSurveyEmployeeProfile.findMany({ where, orderBy: { displayName: 'asc' } }) : [] - const usersByDing = employees.length > 0 + const dingtalkUserIds = employees.map((employee) => clean(employee.dingtalkUserId)).filter((item): item is string => Boolean(item)) + const dingEmails = employees.map((employee) => clean(employee.dingEmail)).filter((item): item is string => Boolean(item)) + const userLookupOr = [ + ...(dingtalkUserIds.length > 0 ? [{ dingtalkUserId: { in: dingtalkUserIds } }] : []), + ...(dingEmails.length > 0 ? [{ email: { in: dingEmails } }] : []), + ] + const users = userLookupOr.length > 0 ? await prisma.user.findMany({ - where: { - dingtalkUserId: { - in: employees.map((employee) => employee.dingtalkUserId).filter((item): item is string => Boolean(item)), - }, - }, - select: { id: true, dingtalkUserId: true }, + where: { OR: userLookupOr }, + select: { id: true, dingtalkUserId: true, email: true }, }) : [] - const userIdByDing = new Map(usersByDing.map((user) => [user.dingtalkUserId, user.id])) + const userIdByDing = new Map(users.map((user) => [user.dingtalkUserId, user.id]).filter(([dingtalkUserId]) => Boolean(dingtalkUserId)) as Array<[string, string]>) + const userIdByEmail = new Map(users.map((user) => [user.email, user.id])) const deduped = new Map() for (const employee of employees) { const dingtalkUserId = employee.dingtalkUserId || null - const userId = dingtalkUserId ? userIdByDing.get(dingtalkUserId) || null : null + const userId = (dingtalkUserId ? userIdByDing.get(dingtalkUserId) || null : null) + || (employee.dingEmail ? userIdByEmail.get(employee.dingEmail) || null : null) const evaluatorKey = getEvaluatorKey({ employeeId: employee.id, userId,