gongxiangp2/lib/performance-survey-report-sync.ts
2026-07-03 18:09:59 +08:00

1129 lines
44 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
}
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 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', '在职状态'],
}
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
}
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 flowerBookParts = parseDepartmentParts(row.flowerBookDept)
if (flowerBookParts.length > 0) return flowerBookParts
const pathParts = parseDepartmentParts(row.departmentPath)
const nameParts = parseDepartmentParts(row.departmentName)
if (pathParts.length === 0) return nameParts
if (nameParts.length === 0) return pathParts
const missingNameParts = nameParts.filter((part) => !pathParts.includes(part))
return missingNameParts.length > 0 ? [...missingNameParts, ...pathParts] : pathParts
}
function joinDepartmentParts(parts: string[]) {
return parts.length > 0 ? parts.join('/') : null
}
function getDepartmentLeaf(parts: string[]) {
return parts.length > 0 ? parts[parts.length - 1] || parts[0] || null : null
}
function getLevelType(positionLevel?: string | null) {
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
}
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),
}
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 ')}`
: ''
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) {
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 activeFilter = e.active ? `WHERE (e.${quoteIdentifier(e.active)} = 1 OR e.${quoteIdentifier(e.active)} = '1')` : ''
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
${activeFilter}
ORDER BY COALESCE(${columnExpr('e', e.name)}, ${columnExpr('e', e.jobNumber)}, ${columnExpr('e', e.dingtalkUserId)}) ASC
`
}
function buildRegisterListSql(registerColumns: TableColumns) {
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
ORDER BY COALESCE(${columnExpr('r', r.flowerName)}, ${columnExpr('r', r.registerName)}, ${columnExpr('r', r.jobNumber)}, ${columnExpr('r', r.dingtalkUserId)}) ASC
`
}
function buildFlowerListSql(flowerColumns: TableColumns) {
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),
}
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
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() {
const identities = await listMaterialAccountSyncIdentities()
const dingtalkUserIds = Array.from(new Set(identities.map((item) => clean(item.dingtalkUserId)).filter(Boolean))) as string[]
const emails = Array.from(new Set(identities.map((item) => clean(item.email)).filter(Boolean))) as string[]
const names = Array.from(new Set(
identities
.flatMap((item) => [item.flowerName, item.displayName, item.realName, item.username])
.map((item) => clean(item))
.filter(Boolean),
)) as string[]
const or: any[] = []
if (dingtalkUserIds.length > 0) {
or.push({ dingtalkUserId: { in: dingtalkUserIds } })
}
if (emails.length > 0) {
or.push({ dingEmail: { in: emails } })
}
if (names.length > 0) {
or.push({ displayName: { in: names } })
or.push({ flowerName: { in: names } })
or.push({ realName: { in: names } })
}
return or.length > 0 ? { OR: or } : { id: { in: [] } }
}
function getIndexed(index: Map<string, ReportEmployeeRow>, ...keys: unknown[]) {
for (const key of keys) {
const normalized = normalizedKey(key)
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,
): Promise<ReportEmployeeRow[]> {
const result = await pool.request().input('limit', sql.Int, limit).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 mergedRows.slice(0, limit)
}
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 = getDepartmentLeaf(departmentParts) || parseDepartmentValue(row.flowerBookDept) || clean(row.departmentName)
const departmentPath = joinDepartmentParts(departmentParts) || clean(row.departmentPath)
const positionTitle = clean(row.flowerBookJob) || clean(row.positionTitle) || clean(row.contactTitle)
const managerName = clean(row.flowerBookManager) || clean(row.flowerBookDirector) || clean(row.managerName) || clean(extension['主管'])
const employmentStatus = clean(row.flowerBookEmployeeStatus) || clean(row.employmentStatus)
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,
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 row = result.recordset?.[0] as ReportEmployeeRow | undefined
if (!row) return null
const normalized = normalizeEmployeeRow(row)
;(normalized as any).sourcePayload = {
...(normalized.sourcePayload || {}),
materialUserId: identity.userId || null,
materialUserEmail: clean(identity.email),
}
return normalized
}
async function syncPerformanceSurveyEmployeeByIdentityWithContext(
pool: any,
sql: typeof import('mssql'),
schema: Awaited<ReturnType<typeof getReportSchema>>,
identity: ReportIdentity,
) {
const normalized = await fetchNormalizedEmployeeByIdentityWithContext(pool, sql, schema, identity)
if (!normalized) return null
if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) return null
return upsertEmployeeProfile(normalized)
}
async function upsertFallbackEmployeeProfileForUser(identity: ReportIdentity & { userId?: string }) {
const existing = await findExistingEmployeeProfile({
dingtalkUserId: clean(identity.dingtalkUserId),
dingEmail: clean(identity.email),
})
const data = {
dingtalkUserId: clean(identity.dingtalkUserId),
dingEmail: clean(identity.email),
displayName: clean(identity.flowerName) || clean(identity.displayName) || clean(identity.realName) || clean(identity.username) || '未知员工',
realName: clean(identity.realName) || clean(identity.username),
flowerName: clean(identity.flowerName) || null,
departmentName: clean(identity.department),
departmentPath: clean(identity.department),
sourcePayload: { source: 'material_user_fallback', userId: identity.userId || null },
syncedAt: new Date(),
}
if (existing) {
return prisma.performanceSurveyEmployeeProfile.update({
where: { id: existing.id },
data,
})
}
return prisma.performanceSurveyEmployeeProfile.create({
data: {
id: randomUUID(),
...data,
},
})
}
export async function syncPerformanceSurveyEmployeeByIdentity(identity: ReportIdentity) {
if (!isPerformanceSurveyReportDbConfigured()) return null
const { sql, pool } = await getReportPool()
try {
const schema = await getReportSchema(pool)
const 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()
}
}
export async function ensurePerformanceSurveyEmployeeProfileForUser(identity: ReportIdentity & { userId?: string }) {
const existing = await findExistingEmployeeProfile({
dingtalkUserId: clean(identity.dingtalkUserId),
dingEmail: clean(identity.email),
})
if (isPerformanceSurveyReportDbConfigured() && (!existing || shouldRefreshProfile(existing))) {
try {
const synced = await syncPerformanceSurveyEmployeeByIdentity(identity)
if (synced) return synced
} catch (error) {
console.warn('[performance-survey] 人员档案同步失败,使用本地兜底档案:', error)
}
}
if (existing) return existing
return upsertFallbackEmployeeProfileForUser(identity)
}
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()
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)
let upserted = 0
let fallback = 0
let skipped = 0
let failed = 0
for (const identity of identities) {
if (!clean(identity.dingtalkUserId) && !clean(identity.email) && !clean(identity.username) && !clean(identity.realName)) {
skipped += 1
continue
}
try {
const synced = await syncPerformanceSurveyEmployeeByIdentityWithContext(pool, sql, schema, identity)
if (synced) {
upserted += 1
} else {
await upsertFallbackEmployeeProfileForUser(identity)
fallback += 1
}
} catch (error) {
failed += 1
console.warn('[performance-survey] 单个人员档案同步失败,使用素材库账号兜底:', {
userId: identity.userId,
email: identity.email,
error,
})
await upsertFallbackEmployeeProfileForUser(identity)
fallback += 1
}
}
const departmentCount = await upsertDepartments()
return {
scannedAccounts: identities.length,
upserted,
fallback,
skipped,
failed,
departmentCount,
syncedAt: new Date().toISOString(),
}
} finally {
await pool.close()
}
}