gongxiangp2/lib/performance-survey-report-sync.ts

1693 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { randomUUID } from 'crypto'
import { prisma } from '@/lib/prisma'
import mssql from 'mssql'
type ReportEmployeeRow = {
dingtalkUserId?: string | null
dingEmail?: string | null
jobNumber?: string | null
employeeDeptIdList?: string | null
contactName?: string | null
contactTitle?: string | null
workPlace?: string | null
extension?: string | null
registerName?: string | null
flowerName?: string | null
realName?: string | null
registerJobNumber?: string | null
positionTitle?: string | null
positionLevel?: string | null
departmentName?: string | null
departmentPath?: string | null
registerDeptIds?: string | null
registerMainDeptId?: string | null
employmentStatus?: string | null
managerDingtalkUserId?: string | null
managerJobNumber?: string | null
managerName?: string | null
flowerBookName?: string | null
flowerBookFlowerName?: string | null
flowerBookDept?: string | null
flowerBookDeptId?: string | null
flowerBookWorkPlace?: string | null
flowerBookJob?: string | null
flowerBookJobLevel?: string | null
flowerBookRole?: string | null
flowerBookDirector?: string | null
flowerBookManager?: string | null
flowerBookEmployeeStatus?: string | null
groupDepartmentName?: string | null
groupDepartmentPath?: string | null
}
type ReportIdentity = {
dingtalkUserId?: string | null
email?: string | null
username?: string | null
realName?: string | null
displayName?: string | null
flowerName?: string | null
userId?: string | null
department?: string | null
}
type TableColumns = Map<string, string>
const EMPLOYEE_TABLE = {
schema: process.env.REPORT_DB_EMPLOYEE_SCHEMA || 'rpt',
table: process.env.REPORT_DB_EMPLOYEE_TABLE || 'hr_employee',
}
const REGISTER_TABLE = {
schema: process.env.REPORT_DB_REGISTER_SCHEMA || 'rpt',
table: process.env.REPORT_DB_REGISTER_TABLE || 'hr_employee_register',
}
const FLOWER_TABLE = {
schema: process.env.REPORT_DB_FLOWER_NAME_SCHEMA || 'rpt',
table: process.env.REPORT_DB_FLOWER_NAME_TABLE || 'hr_flowerName_book',
}
const GROUP_TABLE = {
schema: process.env.REPORT_DB_GROUP_SCHEMA || 'rpt',
table: process.env.REPORT_DB_GROUP_TABLE || 'hr_groups',
}
const INACTIVE_EMPLOYEE_STATUS_KEYWORDS = ['离职', '停用', '退休']
export function getActivePerformanceSurveyEmployeeProfileWhere() {
return {
OR: [
{ employmentStatus: null },
{
AND: INACTIVE_EMPLOYEE_STATUS_KEYWORDS.map((keyword) => ({
employmentStatus: { not: { contains: keyword } },
})),
},
],
} as any
}
const FIELD_CANDIDATES = {
dingtalkUserId: ['userid', 'user_id', 'ding_userid', 'dingtalk_userid', 'dingtalk_user_id', 'userId'],
email: ['email', 'org_email', 'work_email', 'mail', '邮箱'],
jobNumber: ['job_number', 'jobNumber', 'job_no', 'employee_no', '工号'],
name: ['name', 'real_name', 'realName', 'username', '姓名'],
title: ['title', 'position', 'job_title', '岗位'],
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', '真实姓名'],
registerJobNumber: ['sys00-jobNumber', 'job_number', 'jobNumber', '工号'],
positionTitle: ['sys00-position', 'position', 'title', '岗位'],
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', '主管工号'],
managerName: ['manager_name', 'managerName', 'leader_name', '主管'],
flowerBookJobNumber: ['jobNum', 'job_number', 'jobNumber', '工号'],
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', '岗位职级', '职级'],
flowerBookRole: ['role', '角色'],
flowerBookDirector: ['director', 'directorName', '主管'],
flowerBookManager: ['manager', 'managerName', '经理'],
flowerBookEmployeeStatus: ['empStatus', 'employee_status', '在职状态'],
flowerBookStatus: ['status'],
flowerBookLeaveDate: ['leaveDate', 'leave_date', '离职日期'],
}
let dailyWorkerStarted = false
export function isPerformanceSurveyReportDbConfigured() {
return Boolean(
process.env.REPORT_DB_SERVER &&
process.env.REPORT_DB_DATABASE &&
process.env.REPORT_DB_USER &&
process.env.REPORT_DB_PASSWORD,
)
}
function getReportDbConfig() {
const server = process.env.REPORT_DB_SERVER
const database = process.env.REPORT_DB_DATABASE
const user = process.env.REPORT_DB_USER
const password = process.env.REPORT_DB_PASSWORD
const port = Number(process.env.REPORT_DB_PORT || 1433)
if (!server || !database || !user || !password) {
throw new Error('报表库未配置 REPORT_DB_SERVER/REPORT_DB_DATABASE/REPORT_DB_USER/REPORT_DB_PASSWORD')
}
return {
server,
database,
user,
password,
port: Number.isFinite(port) ? port : 1433,
options: {
encrypt: process.env.REPORT_DB_ENCRYPT === 'true',
trustServerCertificate: process.env.REPORT_DB_TRUST_SERVER_CERTIFICATE !== 'false',
enableArithAbort: true,
},
pool: {
max: 4,
min: 0,
idleTimeoutMillis: 30_000,
},
requestTimeout: 60_000,
connectionTimeout: 15_000,
}
}
function clean(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized.length > 0 ? normalized : null
}
const ACCOUNT_PROFILE_WHERE_CACHE_MS = Math.max(
5,
Number(process.env.PERFORMANCE_SURVEY_ACCOUNT_PROFILE_WHERE_CACHE_SECONDS || 60),
) * 1000
const PROFILE_BACKGROUND_REFRESH_MIN_MS = Math.max(
10,
Number(process.env.PERFORMANCE_SURVEY_PROFILE_BACKGROUND_REFRESH_MIN_SECONDS || 60),
) * 1000
const PROFILE_BACKGROUND_REFRESH_RETENTION_MS = Math.max(PROFILE_BACKGROUND_REFRESH_MIN_MS * 10, 10 * 60 * 1000)
const PROFILE_BACKGROUND_REFRESH_MAX_IN_FLIGHT = 32
const PROFILE_BACKGROUND_REFRESH_MAX_HISTORY = 1024
let accountProfileWhereCache: { expiresAt: number; where: any } | null = null
let accountProfileWhereBuildPromise: Promise<any> | null = null
const profileRefreshInFlight = new Map<string, Promise<unknown>>()
const profileRefreshStartedAt = new Map<string, number>()
function pruneProfileRefreshTracking(now = Date.now()) {
for (const [key, startedAt] of Array.from(profileRefreshStartedAt.entries())) {
if (!profileRefreshInFlight.has(key) && now - startedAt >= PROFILE_BACKGROUND_REFRESH_RETENTION_MS) {
profileRefreshStartedAt.delete(key)
}
}
while (profileRefreshStartedAt.size > PROFILE_BACKGROUND_REFRESH_MAX_HISTORY) {
const oldestKey = Array.from(profileRefreshStartedAt.keys()).find((key) => !profileRefreshInFlight.has(key))
if (!oldestKey) break
profileRefreshStartedAt.delete(oldestKey)
}
}
export function invalidatePerformanceSurveyAccountProfileWhereCache() {
accountProfileWhereCache = null
accountProfileWhereBuildPromise = null
}
function parseExtension(value?: string | null) {
if (!value) return {}
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {}
} catch {
return {}
}
}
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 parsed.map((item) => clean(item)).filter((item): item is string => Boolean(item))
}
} catch {
// 继续按普通字符串拆分,兼容报表库里偶发的非标准 JSON 文本。
}
}
return normalized
.split(/[/>||,,;;]/)
.map((item) => item.trim())
.filter(Boolean)
}
function getDepartmentParts(row: ReportEmployeeRow) {
const groupParts = parseDepartmentParts(row.groupDepartmentPath)
if (groupParts.length > 0) return groupParts
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 parseDepartmentIdParts(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(/[/>||,,;;\s]+/)
.map((item) => item.trim())
.filter(Boolean)
}
function getPrimaryDepartmentId(row: ReportEmployeeRow) {
return (
clean(row.registerMainDeptId) ||
parseDepartmentIdParts(row.registerDeptIds)[0] ||
clean(row.flowerBookDeptId) ||
parseDepartmentIdParts(row.employeeDeptIdList)[0] ||
null
)
}
async function resolveDepartmentPathsByIds(pool: any, ids: string[]) {
const numericIds = Array.from(
new Set(
ids
.map((id) => Number(id))
.filter((id) => Number.isInteger(id) && id > 0),
),
)
if (numericIds.length === 0) return new Map<string, string>()
const valuesSql = numericIds.map((_, index) => `(@dept${index})`).join(', ')
const request = pool.request()
numericIds.forEach((id, index) => request.input(`dept${index}`, id))
const result = await request.query(`
;WITH input_depts(dept_id) AS (
SELECT value FROM (VALUES ${valuesSql}) AS v(value)
),
dept_tree AS (
SELECT
input_depts.dept_id,
g.Id,
g.parent_id,
CAST(g.name AS NVARCHAR(MAX)) AS path,
0 AS depth
FROM input_depts
JOIN ${quoteIdentifier(GROUP_TABLE.schema)}.${quoteIdentifier(GROUP_TABLE.table)} g ON g.Id = input_depts.dept_id
UNION ALL
SELECT
dept_tree.dept_id,
parent.Id,
parent.parent_id,
CAST(parent.name + N'/' + dept_tree.path AS NVARCHAR(MAX)) AS path,
dept_tree.depth + 1 AS depth
FROM dept_tree
JOIN ${quoteIdentifier(GROUP_TABLE.schema)}.${quoteIdentifier(GROUP_TABLE.table)} parent ON parent.Id = dept_tree.parent_id
WHERE dept_tree.depth < 20
),
ranked AS (
SELECT
dept_id,
path,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY depth DESC) AS rn
FROM dept_tree
)
SELECT dept_id, path
FROM ranked
WHERE rn = 1
OPTION (MAXRECURSION 50)
`)
return new Map(
(result.recordset || [])
.map((row: { dept_id: number; path: unknown }) => [
String(row.dept_id),
clean(row.path),
] as const)
.filter((item: readonly [string, string | null]): item is readonly [string, string] => Boolean(item[1])),
)
}
async function applyResolvedDepartmentPaths(pool: any, rows: ReportEmployeeRow[]) {
const ids = rows.map((row) => getPrimaryDepartmentId(row)).filter((id): id is string => Boolean(id))
if (ids.length === 0) return rows
const pathById = await resolveDepartmentPathsByIds(pool, ids)
return rows.map((row) => {
const deptId = getPrimaryDepartmentId(row)
const departmentPath = deptId ? clean(pathById.get(String(Number(deptId)))) : null
if (!departmentPath) return row
const parts = parseDepartmentParts(departmentPath)
return {
...row,
groupDepartmentName: getDepartmentLeaf(parts),
groupDepartmentPath: departmentPath,
}
})
}
function getLevelType(positionLevel?: string | null) {
const first = clean(positionLevel)?.slice(0, 1).toUpperCase()
if (first === 'P' || first === 'M') return first
return null
}
function isLeadershipLevel(positionLevel?: string | null) {
return getLevelType(positionLevel) === 'M'
}
function quoteIdentifier(value: string) {
return `[${value.replace(/]/g, ']]')}]`
}
function pickColumn(columns: TableColumns, candidates: string[]) {
for (const candidate of candidates) {
const matched = columns.get(candidate.toLowerCase())
if (matched) return matched
}
return null
}
function columnExpr(alias: string, column: string | null, cast = 'nvarchar(4000)') {
return column ? `${alias}.${quoteIdentifier(column)}` : `CAST(NULL AS ${cast})`
}
function eqCondition(alias: string, column: string | null, paramName: string) {
return column ? `${alias}.${quoteIdentifier(column)} = @${paramName}` : null
}
function notInactiveStatusCondition(alias: string, column: string | null) {
if (!column) return null
const expr = `${alias}.${quoteIdentifier(column)}`
return `(${expr} IS NULL OR ${expr} NOT LIKE N'%离职%')`
}
function emptyDateCondition(alias: string, column: string | null) {
return column ? `${alias}.${quoteIdentifier(column)} IS NULL` : null
}
function getFlowerBookActiveConditions(
alias: string,
flowerColumns: {
status?: string | null
employeeStatus?: string | null
leaveDate?: string | null
},
) {
return [
notInactiveStatusCondition(alias, flowerColumns.status || null),
notInactiveStatusCondition(alias, flowerColumns.employeeStatus || null),
emptyDateCondition(alias, flowerColumns.leaveDate || null),
].filter((item): item is string => Boolean(item))
}
async function getTableColumns(pool: any, schema: string, table: string): Promise<TableColumns> {
const result = await pool
.request()
.input('schema', schema)
.input('table', table)
.query(`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table
`)
return new Map(
((result.recordset || []) as Array<{ COLUMN_NAME: string }>).map((row) => [
String(row.COLUMN_NAME).toLowerCase(),
row.COLUMN_NAME,
]),
)
}
async function getReportSchema(pool: any) {
const employeeColumns = await getTableColumns(pool, EMPLOYEE_TABLE.schema, EMPLOYEE_TABLE.table)
const registerColumns = await getTableColumns(pool, REGISTER_TABLE.schema, REGISTER_TABLE.table)
const flowerColumns = await getTableColumns(pool, FLOWER_TABLE.schema, FLOWER_TABLE.table)
if (employeeColumns.size === 0) {
throw new Error(`报表库未找到人员表 ${EMPLOYEE_TABLE.schema}.${EMPLOYEE_TABLE.table}`)
}
return { employeeColumns, registerColumns, flowerColumns }
}
function buildJoinCondition(employeeColumns: TableColumns, registerColumns: TableColumns) {
const employeeUserId = pickColumn(employeeColumns, FIELD_CANDIDATES.dingtalkUserId)
const registerUserId = pickColumn(registerColumns, FIELD_CANDIDATES.dingtalkUserId)
const employeeJobNumber = pickColumn(employeeColumns, FIELD_CANDIDATES.jobNumber)
const registerJobNumber = pickColumn(registerColumns, FIELD_CANDIDATES.registerJobNumber)
const conditions = [
employeeUserId && registerUserId
? `r.${quoteIdentifier(registerUserId)} = e.${quoteIdentifier(employeeUserId)}`
: null,
employeeJobNumber && registerJobNumber
? `r.${quoteIdentifier(registerJobNumber)} = e.${quoteIdentifier(employeeJobNumber)}`
: null,
].filter(Boolean)
return conditions.length > 0 ? conditions.join(' OR ') : '1 = 0'
}
function buildSelectSql(
employeeColumns: TableColumns,
registerColumns: TableColumns,
flowerColumns: TableColumns,
whereSql: string,
limitSql = '@limit',
) {
const e = {
dingtalkUserId: pickColumn(employeeColumns, FIELD_CANDIDATES.dingtalkUserId),
email: pickColumn(employeeColumns, FIELD_CANDIDATES.email),
jobNumber: pickColumn(employeeColumns, FIELD_CANDIDATES.jobNumber),
name: pickColumn(employeeColumns, FIELD_CANDIDATES.name),
title: pickColumn(employeeColumns, FIELD_CANDIDATES.title),
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),
}
const r = {
dingtalkUserId: pickColumn(registerColumns, FIELD_CANDIDATES.dingtalkUserId),
email: pickColumn(registerColumns, FIELD_CANDIDATES.email),
registerName: pickColumn(registerColumns, FIELD_CANDIDATES.registerName),
flowerName: pickColumn(registerColumns, FIELD_CANDIDATES.flowerName),
realName: pickColumn(registerColumns, FIELD_CANDIDATES.registerRealName),
jobNumber: pickColumn(registerColumns, FIELD_CANDIDATES.registerJobNumber),
positionTitle: pickColumn(registerColumns, FIELD_CANDIDATES.positionTitle),
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),
managerName: pickColumn(registerColumns, FIELD_CANDIDATES.managerName),
}
const f = {
jobNumber: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJobNumber),
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),
role: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookRole),
director: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDirector),
manager: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookManager),
employeeStatus: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookEmployeeStatus),
status: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookStatus),
leaveDate: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookLeaveDate),
}
const activeFilter = e.active ? `AND (e.${quoteIdentifier(e.active)} = 1 OR e.${quoteIdentifier(e.active)} = '1')` : ''
const joinSql = registerColumns.size > 0
? `LEFT JOIN ${quoteIdentifier(REGISTER_TABLE.schema)}.${quoteIdentifier(REGISTER_TABLE.table)} r ON ${buildJoinCondition(employeeColumns, registerColumns)}`
: ''
const flowerJoinConditions = [
f.jobNumber && r.jobNumber ? `f.${quoteIdentifier(f.jobNumber)} = r.${quoteIdentifier(r.jobNumber)}` : null,
f.jobNumber && e.jobNumber ? `f.${quoteIdentifier(f.jobNumber)} = e.${quoteIdentifier(e.jobNumber)}` : null,
f.flowerName && r.flowerName ? `f.${quoteIdentifier(f.flowerName)} = r.${quoteIdentifier(r.flowerName)}` : null,
].filter(Boolean)
const flowerJoinSql = flowerColumns.size > 0 && flowerJoinConditions.length > 0
? `LEFT JOIN ${quoteIdentifier(FLOWER_TABLE.schema)}.${quoteIdentifier(FLOWER_TABLE.table)} f ON (${flowerJoinConditions.join(' OR ')})${getFlowerBookActiveConditions('f', f).map((condition) => ` AND ${condition}`).join('')}`
: ''
return `
SELECT TOP (${limitSql})
COALESCE(${columnExpr('e', e.dingtalkUserId)}, ${columnExpr('r', r.dingtalkUserId)}) AS "dingtalkUserId",
COALESCE(${columnExpr('e', e.email)}, ${columnExpr('r', r.email)}) AS "dingEmail",
${columnExpr('e', e.jobNumber)} AS "jobNumber",
${columnExpr('e', e.name)} AS "contactName",
${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",
${columnExpr('r', r.jobNumber)} AS "registerJobNumber",
${columnExpr('r', r.positionTitle)} AS "positionTitle",
${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",
COALESCE(${columnExpr('r', r.managerName)}, ${columnExpr('e', e.managerName)}) AS "managerName",
${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",
${columnExpr('f', f.role)} AS "flowerBookRole",
${columnExpr('f', f.director)} AS "flowerBookDirector",
${columnExpr('f', f.manager)} AS "flowerBookManager",
${columnExpr('f', f.employeeStatus)} AS "flowerBookEmployeeStatus"
FROM ${quoteIdentifier(EMPLOYEE_TABLE.schema)}.${quoteIdentifier(EMPLOYEE_TABLE.table)} e
${joinSql}
${flowerJoinSql}
WHERE ${whereSql}
${activeFilter}
ORDER BY COALESCE(${columnExpr('r', r.flowerName)}, ${columnExpr('r', r.registerName)}, ${columnExpr('e', e.name)}, ${columnExpr('e', e.jobNumber)}) ASC
`
}
function buildEmployeeListSql(employeeColumns: TableColumns, whereSql?: string) {
const e = {
dingtalkUserId: pickColumn(employeeColumns, FIELD_CANDIDATES.dingtalkUserId),
email: pickColumn(employeeColumns, FIELD_CANDIDATES.email),
jobNumber: pickColumn(employeeColumns, FIELD_CANDIDATES.jobNumber),
name: pickColumn(employeeColumns, FIELD_CANDIDATES.name),
title: pickColumn(employeeColumns, FIELD_CANDIDATES.title),
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),
}
const whereParts = [
e.active ? `(e.${quoteIdentifier(e.active)} = 1 OR e.${quoteIdentifier(e.active)} = '1')` : null,
whereSql ? `(${whereSql})` : null,
].filter(Boolean)
const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''
return `
SELECT TOP (@limit)
${columnExpr('e', e.dingtalkUserId)} AS "dingtalkUserId",
${columnExpr('e', e.email)} AS "dingEmail",
${columnExpr('e', e.jobNumber)} AS "jobNumber",
${columnExpr('e', e.name)} AS "contactName",
${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"
FROM ${quoteIdentifier(EMPLOYEE_TABLE.schema)}.${quoteIdentifier(EMPLOYEE_TABLE.table)} e
${whereClause}
ORDER BY COALESCE(${columnExpr('e', e.name)}, ${columnExpr('e', e.jobNumber)}, ${columnExpr('e', e.dingtalkUserId)}) ASC
`
}
function buildRegisterListSql(registerColumns: TableColumns, whereSql?: string) {
const r = {
dingtalkUserId: pickColumn(registerColumns, FIELD_CANDIDATES.dingtalkUserId),
email: pickColumn(registerColumns, FIELD_CANDIDATES.email),
registerName: pickColumn(registerColumns, FIELD_CANDIDATES.registerName),
flowerName: pickColumn(registerColumns, FIELD_CANDIDATES.flowerName),
realName: pickColumn(registerColumns, FIELD_CANDIDATES.registerRealName),
jobNumber: pickColumn(registerColumns, FIELD_CANDIDATES.registerJobNumber),
positionTitle: pickColumn(registerColumns, FIELD_CANDIDATES.positionTitle),
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),
managerName: pickColumn(registerColumns, FIELD_CANDIDATES.managerName),
}
return `
SELECT TOP (@limit)
${columnExpr('r', r.dingtalkUserId)} AS "dingtalkUserId",
${columnExpr('r', r.email)} AS "dingEmail",
${columnExpr('r', r.registerName)} AS "registerName",
${columnExpr('r', r.flowerName)} AS "flowerName",
${columnExpr('r', r.realName)} AS "realName",
${columnExpr('r', r.jobNumber)} AS "registerJobNumber",
${columnExpr('r', r.positionTitle)} AS "positionTitle",
${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",
${columnExpr('r', r.managerName)} AS "managerName"
FROM ${quoteIdentifier(REGISTER_TABLE.schema)}.${quoteIdentifier(REGISTER_TABLE.table)} r
${whereSql ? `WHERE ${whereSql}` : ''}
ORDER BY COALESCE(${columnExpr('r', r.flowerName)}, ${columnExpr('r', r.registerName)}, ${columnExpr('r', r.jobNumber)}, ${columnExpr('r', r.dingtalkUserId)}) ASC
`
}
function buildFlowerListSql(flowerColumns: TableColumns, whereSql?: string) {
const f = {
jobNumber: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJobNumber),
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),
role: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookRole),
director: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDirector),
manager: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookManager),
employeeStatus: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookEmployeeStatus),
status: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookStatus),
leaveDate: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookLeaveDate),
}
const whereParts = [
...getFlowerBookActiveConditions('f', f),
whereSql ? `(${whereSql})` : null,
].filter(Boolean)
const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''
return `
SELECT TOP (@limit)
${columnExpr('f', f.jobNumber)} AS "registerJobNumber",
${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",
${columnExpr('f', f.role)} AS "flowerBookRole",
${columnExpr('f', f.director)} AS "flowerBookDirector",
${columnExpr('f', f.manager)} AS "flowerBookManager",
${columnExpr('f', f.employeeStatus)} AS "flowerBookEmployeeStatus"
FROM ${quoteIdentifier(FLOWER_TABLE.schema)}.${quoteIdentifier(FLOWER_TABLE.table)} f
${whereClause}
ORDER BY COALESCE(${columnExpr('f', f.flowerName)}, ${columnExpr('f', f.name)}, ${columnExpr('f', f.jobNumber)}) ASC
`
}
function normalizedKey(value: unknown) {
return clean(value)?.toLowerCase() || null
}
function addToIndex<T>(index: Map<string, T>, key: unknown, value: T) {
const normalized = normalizedKey(key)
if (normalized && !index.has(normalized)) {
index.set(normalized, value)
}
}
function indexRows(rows: ReportEmployeeRow[], keys: Array<keyof ReportEmployeeRow>) {
const index = new Map<string, ReportEmployeeRow>()
for (const row of rows) {
for (const key of keys) {
addToIndex(index, row[key], row)
}
}
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() {
if (accountProfileWhereCache && accountProfileWhereCache.expiresAt > Date.now()) {
return accountProfileWhereCache.where
}
if (accountProfileWhereBuildPromise) {
return accountProfileWhereBuildPromise
}
accountProfileWhereBuildPromise = buildPerformanceSurveyAccountProfileWhere()
.then((where) => {
accountProfileWhereCache = {
where,
expiresAt: Date.now() + ACCOUNT_PROFILE_WHERE_CACHE_MS,
}
return where
})
.finally(() => {
accountProfileWhereBuildPromise = null
})
return accountProfileWhereBuildPromise
}
async function buildPerformanceSurveyAccountProfileWhere() {
const identities = await listMaterialAccountSyncIdentities()
const userIds = Array.from(new Set(identities.map((item) => clean(item.userId)).filter(Boolean))) as string[]
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 or: any[] = []
if (dingtalkUserIds.length > 0) {
or.push({ dingtalkUserId: { in: dingtalkUserIds } })
}
if (emails.length > 0) {
or.push({ dingEmail: { in: emails } })
}
if (userIds.length > 0) {
for (const userId of userIds) {
or.push({ sourcePayload: { path: ['materialUserId'], equals: userId } })
or.push({ sourcePayload: { path: ['userId'], equals: userId } })
}
}
const accountWhere = or.length > 0 ? { OR: or } : { id: { in: [] } }
return { AND: [accountWhere, getActivePerformanceSurveyEmployeeProfileWhere()] }
}
function uniqueClean(values: unknown[]) {
return Array.from(new Set(values.map((item) => clean(item)).filter((item): item is string => Boolean(item))))
}
function getIdentityNames(identity: ReportIdentity) {
return uniqueClean([identity.flowerName, identity.displayName, identity.realName, identity.username])
}
function getIdentityMatchKeys(identity: ReportIdentity) {
return [
...uniqueClean([identity.dingtalkUserId]).map((item) => `ding:${item.toLowerCase()}`),
...uniqueClean([identity.email]).map((item) => `email:${item.toLowerCase()}`),
...getIdentityNames(identity).map((item) => `name:${item.toLowerCase()}`),
]
}
function getNormalizedProfileMatchKeys(profile: ReturnType<typeof normalizeEmployeeRow>) {
return [
...uniqueClean([profile.dingtalkUserId]).map((item) => `ding:${item.toLowerCase()}`),
...uniqueClean([profile.dingEmail]).map((item) => `email:${item.toLowerCase()}`),
...uniqueClean([profile.displayName, profile.flowerName, profile.realName]).map((item) => `name:${item.toLowerCase()}`),
]
}
function getIdentityProfileMatchScore(identity: ReportIdentity, profile: ReturnType<typeof normalizeEmployeeRow>) {
const identityDing = clean(identity.dingtalkUserId)?.toLowerCase()
const profileDing = clean(profile.dingtalkUserId)?.toLowerCase()
if (identityDing && profileDing && identityDing === profileDing) return 3
const identityEmail = clean(identity.email)?.toLowerCase()
const profileEmail = clean(profile.dingEmail)?.toLowerCase()
if (identityEmail && profileEmail && identityEmail === profileEmail) return 2
const profileNames = new Set(uniqueClean([profile.displayName, profile.flowerName, profile.realName]).map((item) => item.toLowerCase()))
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<typeof getAccountScopedValues>,
) {
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<ReturnType<typeof getReportSchema>>,
values: ReturnType<typeof getAccountScopedValues>,
) {
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<string | null>
},
values: ReturnType<typeof getAccountScopedValues>,
) {
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<string, ReportEmployeeRow>, ...keys: unknown[]) {
for (const key of keys) {
const normalized = normalizedKey(key)
if (!normalized) continue
const matched = index.get(normalized)
if (matched) return matched
}
return null
}
function mergeReportRows(...rows: Array<ReportEmployeeRow | null | undefined>): ReportEmployeeRow {
const merged: ReportEmployeeRow = {}
for (const row of rows) {
if (!row) continue
for (const [key, value] of Object.entries(row) as Array<[keyof ReportEmployeeRow, unknown]>) {
if (clean(merged[key]) || clean(value) === null) continue
;(merged as Record<string, unknown>)[key] = value
}
}
return merged
}
async function queryReportRows(
pool: any,
sql: typeof import('mssql'),
query: string,
limit: number,
bindInputs?: (request: any) => void,
): Promise<ReportEmployeeRow[]> {
const request = pool.request().input('limit', sql.Int, limit)
bindInputs?.(request)
const result = await request.query(query)
return (result.recordset || []) as ReportEmployeeRow[]
}
async function fetchFullSyncRows(
pool: any,
sql: typeof import('mssql'),
schema: Awaited<ReturnType<typeof getReportSchema>>,
limit: number,
) {
const employeeRows = await queryReportRows(pool, sql, buildEmployeeListSql(schema.employeeColumns), limit)
const registerRows = schema.registerColumns.size > 0
? await queryReportRows(pool, sql, buildRegisterListSql(schema.registerColumns), limit)
: []
const flowerRows = schema.flowerColumns.size > 0
? await queryReportRows(pool, sql, buildFlowerListSql(schema.flowerColumns), Math.max(limit, Math.min(limit * 3, 10_000)))
: []
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<string>()
const pushMerged = (row: ReportEmployeeRow) => {
const normalized = normalizeEmployeeRow(row)
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))
}
return applyResolvedDepartmentPaths(pool, mergedRows.slice(0, limit))
}
async function fetchAccountScopedSyncRows(
pool: any,
sql: typeof import('mssql'),
schema: Awaited<ReturnType<typeof getReportSchema>>,
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<string>()
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,
flowerColumns: TableColumns,
identity: ReportIdentity,
) {
const conditions: string[] = []
const e = {
dingtalkUserId: pickColumn(employeeColumns, FIELD_CANDIDATES.dingtalkUserId),
email: pickColumn(employeeColumns, FIELD_CANDIDATES.email),
name: pickColumn(employeeColumns, FIELD_CANDIDATES.name),
}
const r = {
dingtalkUserId: pickColumn(registerColumns, FIELD_CANDIDATES.dingtalkUserId),
email: pickColumn(registerColumns, FIELD_CANDIDATES.email),
flowerName: pickColumn(registerColumns, FIELD_CANDIDATES.flowerName),
realName: pickColumn(registerColumns, FIELD_CANDIDATES.registerRealName),
registerName: pickColumn(registerColumns, FIELD_CANDIDATES.registerName),
}
const f = {
flowerName: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookFlowerName),
name: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookName),
}
if (clean(identity.dingtalkUserId)) {
conditions.push(
...[
eqCondition('e', e.dingtalkUserId, 'dingtalkUserId'),
eqCondition('r', r.dingtalkUserId, 'dingtalkUserId'),
].filter((item): item is string => Boolean(item)),
)
}
if (clean(identity.email)) {
conditions.push(
...[
eqCondition('e', e.email, 'email'),
eqCondition('r', r.email, 'email'),
].filter((item): item is string => Boolean(item)),
)
}
const nameParam = clean(identity.flowerName) || clean(identity.displayName) || clean(identity.realName) || clean(identity.username)
if (nameParam) {
conditions.push(
...[
eqCondition('r', r.flowerName, 'name'),
eqCondition('r', r.realName, 'name'),
eqCondition('r', r.registerName, 'name'),
eqCondition('e', e.name, 'name'),
eqCondition('f', f.flowerName, 'name'),
eqCondition('f', f.name, 'name'),
].filter((item): item is string => Boolean(item)),
)
}
return {
whereSql: conditions.length > 0 ? `(${conditions.join(' OR ')})` : '1 = 0',
nameParam,
}
}
function normalizeEmployeeRow(row: ReportEmployeeRow) {
const extension = parseExtension(row.extension)
const dingtalkUserId = clean(row.dingtalkUserId)
const dingEmail = clean(row.dingEmail)
const jobNumber = clean(row.registerJobNumber) || clean(row.jobNumber)
const flowerName = clean(row.flowerBookFlowerName) || clean(row.flowerName) || clean(extension['花名'])
const realName = clean(row.realName) || clean(row.flowerBookName) || clean(row.registerName) || clean(row.contactName)
const displayName = flowerName || realName || jobNumber || dingtalkUserId || dingEmail || '未命名员工'
const positionLevel = clean(row.flowerBookJobLevel) || clean(row.positionLevel) || clean(extension['岗位职级'])
const levelType = getLevelType(positionLevel)
const departmentParts = getDepartmentParts(row)
const departmentName = clean(row.groupDepartmentName) || getDepartmentLeaf(departmentParts) || parseDepartmentValue(row.flowerBookDept) || clean(row.departmentName)
const departmentPath = clean(row.groupDepartmentPath) || 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)
const workPlace = clean(row.flowerBookWorkPlace) || clean(row.workPlace)
return {
dingtalkUserId,
dingEmail,
jobNumber,
flowerName,
realName,
displayName,
departmentName,
departmentPath,
positionTitle,
positionLevel,
levelType,
isLeadership: isLeadershipLevel(positionLevel),
managerDingtalkUserId: clean(row.managerDingtalkUserId) || clean(extension['主管userid']),
managerJobNumber: clean(row.managerJobNumber) || clean(extension['主管工号']),
managerName,
employmentStatus,
workPlace,
sourcePayload: {
source: 'JSReportReplica.rpt',
syncedFields: {
hasDingtalkUserId: Boolean(dingtalkUserId),
hasDingEmail: Boolean(dingEmail),
jobNumber,
displayName,
departmentName,
departmentPath,
primaryDeptId: getPrimaryDepartmentId(row),
employeeDeptIdList: clean(row.employeeDeptIdList),
registerDeptIds: clean(row.registerDeptIds),
registerMainDeptId: clean(row.registerMainDeptId),
flowerBookDeptId: clean(row.flowerBookDeptId),
positionLevel,
levelType,
flowerBookRole: clean(row.flowerBookRole),
},
},
syncedAt: new Date(),
}
}
async function findExistingEmployeeProfile(data: Partial<ReturnType<typeof normalizeEmployeeRow>>) {
const or = [
data.dingtalkUserId ? { dingtalkUserId: data.dingtalkUserId } : null,
data.jobNumber ? { jobNumber: data.jobNumber } : null,
data.dingEmail ? { dingEmail: data.dingEmail } : null,
].filter(Boolean) as any[]
if (or.length === 0) return null
return prisma.performanceSurveyEmployeeProfile.findFirst({ where: { OR: or } })
}
async function upsertEmployeeProfile(data: ReturnType<typeof normalizeEmployeeRow>) {
const existing = await findExistingEmployeeProfile(data)
if (existing) {
return prisma.performanceSurveyEmployeeProfile.update({
where: { id: existing.id },
data,
})
}
return prisma.performanceSurveyEmployeeProfile.create({
data: {
id: randomUUID(),
...data,
},
})
}
function getParentDepartmentName(departmentName?: string | null, departmentPath?: string | null) {
const current = clean(departmentName)
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: {
AND: [
accountWhere,
{ departmentName: { not: null } },
],
},
select: {
departmentName: true,
departmentPath: true,
},
})
const grouped = new Map<string, { name: string; parentName: string | null; count: number }>()
for (const employee of employees) {
if (!employee.departmentName) continue
const existing = grouped.get(employee.departmentName)
grouped.set(employee.departmentName, {
name: employee.departmentName,
parentName: existing?.parentName || getParentDepartmentName(employee.departmentName, employee.departmentPath),
count: (existing?.count || 0) + 1,
})
}
let count = 0
for (const item of Array.from(grouped.values())) {
await prisma.performanceSurveyDepartmentProfile.upsert({
where: { name: item.name },
update: {
parentName: item.parentName,
employeeCount: item.count,
syncedAt: new Date(),
},
create: {
id: randomUUID(),
name: item.name,
parentName: item.parentName,
employeeCount: item.count,
syncedAt: new Date(),
},
})
count += 1
}
return count
}
function shouldRefreshProfile(profile: { syncedAt?: Date | null; departmentName?: string | null; positionLevel?: string | null }) {
if (!profile.syncedAt) return true
const hours = Math.max(1, Number(process.env.PERFORMANCE_SURVEY_PROFILE_SYNC_HOURS || 24))
return Date.now() - profile.syncedAt.getTime() > hours * 60 * 60 * 1000
}
async function getReportPool() {
const sql = mssql as typeof import('mssql')
return {
sql,
pool: await sql.connect(getReportDbConfig() as any),
}
}
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 rows = await applyResolvedDepartmentPaths(pool, result.recordset || [])
const row = rows[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,
materialUserId: identity.userId || null,
materialUserEmail: clean(identity.email),
},
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 profile = await syncPerformanceSurveyEmployeeByIdentityWithContext(pool, sql, schema, identity)
if (profile) await upsertDepartments()
return profile
} finally {
await pool.close()
}
}
export async function previewPerformanceSurveyEmployeeByIdentity(identity: ReportIdentity) {
if (!isPerformanceSurveyReportDbConfigured()) return null
const { sql, pool } = await getReportPool()
try {
const schema = await getReportSchema(pool)
return fetchNormalizedEmployeeByIdentityWithContext(pool, sql, schema, identity)
} finally {
await pool.close()
}
}
function getProfileRefreshKey(identity: ReportIdentity & { userId?: string }) {
return clean(identity.dingtalkUserId)
|| clean(identity.email)?.toLowerCase()
|| clean(identity.userId)
|| clean(identity.username)?.toLowerCase()
|| clean(identity.realName)
|| clean(identity.displayName)
|| 'anonymous'
}
function refreshPerformanceSurveyEmployeeProfileInBackground(identity: ReportIdentity & { userId?: string }) {
if (!isPerformanceSurveyReportDbConfigured()) return
const key = getProfileRefreshKey(identity)
pruneProfileRefreshTracking()
if (profileRefreshInFlight.has(key)) return
if (profileRefreshInFlight.size >= PROFILE_BACKGROUND_REFRESH_MAX_IN_FLIGHT) return
const lastStartedAt = profileRefreshStartedAt.get(key) || 0
if (Date.now() - lastStartedAt < PROFILE_BACKGROUND_REFRESH_MIN_MS) return
profileRefreshStartedAt.set(key, Date.now())
const refreshPromise = syncPerformanceSurveyEmployeeByIdentity(identity)
.catch((error) => {
console.warn('[performance-survey] 后台人员档案同步失败:', error)
})
.finally(() => {
profileRefreshInFlight.delete(key)
pruneProfileRefreshTracking()
})
profileRefreshInFlight.set(key, refreshPromise)
pruneProfileRefreshTracking()
}
export async function ensurePerformanceSurveyEmployeeProfileForUser(identity: ReportIdentity & { userId?: string }) {
const existing = await findExistingEmployeeProfile({
dingtalkUserId: clean(identity.dingtalkUserId),
dingEmail: clean(identity.email),
})
if (existing) {
if (shouldRefreshProfile(existing)) {
refreshPerformanceSurveyEmployeeProfileInBackground(identity)
}
return existing
}
const fallback = await upsertFallbackEmployeeProfileForUser(identity)
refreshPerformanceSurveyEmployeeProfileInBackground(identity)
return fallback
}
export function ensurePerformanceSurveyDailyEmployeeSyncStarted() {
if (dailyWorkerStarted) return
dailyWorkerStarted = true
const intervalHours = Math.max(1, Number(process.env.PERFORMANCE_SURVEY_DAILY_SYNC_INTERVAL_HOURS || 24))
const initialDelayMs = Math.max(10, Number(process.env.PERFORMANCE_SURVEY_DAILY_SYNC_INITIAL_DELAY_SECONDS || 300)) * 1000
const intervalMs = intervalHours * 60 * 60 * 1000
const limit = Math.max(1, Math.min(Number(process.env.PERFORMANCE_SURVEY_DAILY_SYNC_LIMIT || 3000), 10_000))
const run = async () => {
if (!isPerformanceSurveyReportDbConfigured()) return
try {
await syncPerformanceSurveyEmployees({ limit })
} catch (error) {
console.warn('[performance-survey] 每日人员档案同步失败:', error)
}
}
const bootstrap = setTimeout(() => void run(), initialDelayMs)
bootstrap.unref?.()
const timer = setInterval(() => void run(), intervalMs)
timer.unref?.()
}
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()
invalidatePerformanceSurveyAccountProfileWhereCache()
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 fetchAccountScopedSyncRows(pool, sql, schema, identities, limit)
const identityIndexesByKey = new Map<string, number[]>()
identities.forEach((identity, index) => {
for (const key of getIdentityMatchKeys(identity)) {
const indexes = identityIndexesByKey.get(key) || []
indexes.push(index)
identityIndexesByKey.set(key, indexes)
}
})
let upserted = 0
let fallback = 0
let skipped = 0
let failed = 0
const matchedIdentityIndexes = new Set<number>()
const bestMatchesByIdentity = new Map<number, {
normalized: ReturnType<typeof normalizeEmployeeRow>
score: number
}>()
for (const row of rows) {
const normalized = normalizeEmployeeRow(row)
if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) {
skipped += 1
continue
}
const candidateIndexes = new Set<number>()
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<string>()
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
if (syncedProfileKeys.has(profileKey)) {
matchedIdentityIndexes.add(index)
continue
}
try {
await upsertEmployeeProfile(normalized)
upserted += 1
syncedProfileKeys.add(profileKey)
matchedIdentityIndexes.add(index)
} catch (error) {
failed += 1
console.warn('[performance-survey] 单个人员档案同步失败,使用素材库账号兜底:', {
dingtalkUserId: normalized.dingtalkUserId,
dingEmail: normalized.dingEmail,
error,
})
}
}
for (let index = 0; index < identities.length; index += 1) {
const identity = identities[index]
if (matchedIdentityIndexes.has(index)) continue
if (!clean(identity.dingtalkUserId) && !clean(identity.email) && !clean(identity.username) && !clean(identity.realName)) {
skipped += 1
continue
}
await upsertFallbackEmployeeProfileForUser(identity)
fallback += 1
}
const departmentCount = await upsertDepartments()
invalidatePerformanceSurveyAccountProfileWhereCache()
return {
scannedAccounts: identities.length,
upserted,
fallback,
skipped,
failed,
departmentCount,
syncedAt: new Date().toISOString(),
}
} finally {
await pool.close()
}
}