1129 lines
44 KiB
TypeScript
1129 lines
44 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
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|