fix: scope performance survey roster sync

This commit is contained in:
chenpei 2026-07-03 18:09:59 +08:00
parent 57f46fca6f
commit 7b4d70360d
3 changed files with 410 additions and 207 deletions

View File

@ -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
)} ×
</button>
))}
</div>

View File

@ -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<string, string>
@ -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<keyof ReportEmployeeRo
return index
}
function getSyncableUserWhere() {
return {
archivedAt: null,
NOT: [
{ status: 'archived' },
{ status: 'suspended' },
],
} as any
}
async function listMaterialAccountSyncIdentities(limit?: number) {
const users = await prisma.user.findMany({
where: getSyncableUserWhere(),
select: {
id: true,
email: true,
username: true,
realName: true,
dingtalkUserId: true,
department: true,
},
orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }],
...(limit ? { take: limit } : {}),
})
return users.map((user) => ({
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<string, ReportEmployeeRow>, ...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<typeof normalizeEmployeeRo
function getParentDepartmentName(departmentName?: string | null, departmentPath?: string | null) {
const current = clean(departmentName)
const parts = String(departmentPath || '')
.split(/[/>||,,;;]/)
.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<ReturnType<typeof getReportSchema>>,
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<ReturnType<typeof getReportSchema>>,
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<ReportEmployeeRow>(
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<ReportEmployeeRow>(
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(),
}

View File

@ -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<string, {
name: string
employeeCount: number
children: Map<string, number>
}>()
const nodeByPath = new Map<string, any>()
for (const employee of employees) {
const departmentName = clean(employee.departmentName)
const pathParts = parseDepartmentPathParts(employee.departmentPath)
const parts = pathParts.length > 0 ? pathParts : departmentName ? [departmentName] : []
if (parts.length === 0) continue
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<string, number>(),
}
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<string, any>()
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,