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

938 lines
37 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
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
employmentStatus?: string | null
managerDingtalkUserId?: string | null
managerJobNumber?: string | null
managerName?: string | null
flowerBookName?: string | null
flowerBookFlowerName?: string | null
flowerBookDept?: 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
}
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'],
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', '部门路径'],
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', '部门'],
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 normalized = clean(value)
if (!normalized) return null
if (normalized.startsWith('[')) {
try {
const parsed = JSON.parse(normalized)
if (Array.isArray(parsed)) {
return clean(parsed[parsed.length - 1]) || clean(parsed[0])
}
} catch {
return normalized
}
}
return normalized
}
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),
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),
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),
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('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.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.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),
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.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),
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.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),
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.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 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 departmentName = parseDepartmentValue(row.flowerBookDept) || clean(row.departmentName)
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: clean(row.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,
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 = String(departmentPath || '')
.split(/[/>||,,;;]/)
.map((item) => item.trim())
.filter(Boolean)
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 employees = await prisma.performanceSurveyEmployeeProfile.findMany({
where: {
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
if (!profile.departmentName || !profile.positionLevel) 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),
}
}
export async function syncPerformanceSurveyEmployeeByIdentity(identity: ReportIdentity) {
if (!isPerformanceSurveyReportDbConfigured()) return null
const { sql, pool } = await getReportPool()
try {
const schema = await getReportSchema(pool)
const where = buildIdentityWhere(
schema.employeeColumns,
schema.registerColumns,
schema.flowerColumns,
identity,
)
if (where.whereSql === '1 = 0') return null
const request = pool.request().input('limit', sql.Int, 1)
if (clean(identity.dingtalkUserId)) request.input('dingtalkUserId', sql.NVarChar(200), clean(identity.dingtalkUserId))
if (clean(identity.email)) request.input('email', sql.NVarChar(320), clean(identity.email))
if (where.nameParam) request.input('name', sql.NVarChar(200), where.nameParam)
const result = await request.query<ReportEmployeeRow>(
buildSelectSql(
schema.employeeColumns,
schema.registerColumns,
schema.flowerColumns,
where.whereSql,
'@limit',
),
)
const row = result.recordset?.[0]
if (!row) return null
const normalized = normalizeEmployeeRow(row)
if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) return null
const profile = await upsertEmployeeProfile(normalized)
await upsertDepartments()
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)
const where = buildIdentityWhere(
schema.employeeColumns,
schema.registerColumns,
schema.flowerColumns,
identity,
)
if (where.whereSql === '1 = 0') return null
const request = pool.request().input('limit', sql.Int, 1)
if (clean(identity.dingtalkUserId)) request.input('dingtalkUserId', sql.NVarChar(200), clean(identity.dingtalkUserId))
if (clean(identity.email)) request.input('email', sql.NVarChar(320), clean(identity.email))
if (where.nameParam) request.input('name', sql.NVarChar(200), where.nameParam)
const result = await request.query<ReportEmployeeRow>(
buildSelectSql(
schema.employeeColumns,
schema.registerColumns,
schema.flowerColumns,
where.whereSql,
'@limit',
),
)
const row = result.recordset?.[0]
return row ? normalizeEmployeeRow(row) : null
} 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
const fallbackData = {
id: randomUUID(),
dingtalkUserId: clean(identity.dingtalkUserId),
dingEmail: clean(identity.email),
displayName: clean(identity.flowerName) || clean(identity.displayName) || clean(identity.realName) || clean(identity.username) || '未知员工',
realName: clean(identity.realName) || clean(identity.username),
flowerName: clean(identity.flowerName) || null,
sourcePayload: { source: 'material_user_fallback', userId: identity.userId || null },
syncedAt: new Date(),
}
return prisma.performanceSurveyEmployeeProfile.create({ data: fallbackData })
}
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 { sql, pool } = await getReportPool()
try {
const schema = await getReportSchema(pool)
const rows = await fetchFullSyncRows(pool, sql, schema, limit)
let upserted = 0
let skipped = 0
for (const row of rows) {
const normalized = normalizeEmployeeRow(row)
if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) {
skipped += 1
continue
}
await upsertEmployeeProfile(normalized)
upserted += 1
}
const departmentCount = await upsertDepartments()
return {
upserted,
skipped,
departmentCount,
syncedAt: new Date().toISOString(),
}
} finally {
await pool.close()
}
}