1693 lines
65 KiB
TypeScript
1693 lines
65 KiB
TypeScript
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()
|
||
}
|
||
}
|