素材库与考核调研此前各有一套人事数据,彼此没有外键,靠姓名、邮箱在运行时 逐层软匹配。本次把身份判定收敛到钉钉唯一值,并让组织架构与人员状态跟随 上游 HR 系统自动更新。 人员身份 - 新增账号与权威人员档案的正式绑定表,活跃绑定双向唯一 - users.dingtalkUserId 补上唯一约束,数据库层面杜绝一人多号 - 移除钉钉登录的同名自动合并,同名冲突一律转人工核对 - 看板删除特判由比对用户名改为比对钉钉唯一值,避免同名越权 组织架构 - 新增 OrganizationUnit,直接镜像上游 HR 部门树 - 不再从员工部门路径倒推,同名部门不会再被合并 - 上游已删除的部门下线而非物理删除 人员状态 - 新增规范化的 employmentState,取代对混杂原始值做关键词匹配 - 接入上游离职表,离职判定不再依赖中文关键词 - 离职联动:归档账号并把素材托管给所在部门,默认只处理离职满 30 天者 素材归属 - 归属变更纳入正式转移,记录最初上传者、转移流水与理由 - 新增素材托管部门,归属与署名分离:托管只改维护责任,不动署名 - 编辑素材的权限判定统一到 canManageMaterial - 修复任务-详情上传把邮箱误存为作者名的问题 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2237 lines
83 KiB
TypeScript
2237 lines
83 KiB
TypeScript
import { randomUUID } from 'crypto'
|
||
import { prisma } from '@/lib/prisma'
|
||
import mssql from 'mssql'
|
||
|
||
type ResolvedEmployeeDepartment = {
|
||
sourceDeptId: string
|
||
departmentName: string | null
|
||
departmentPath: string | null
|
||
isPrimary: boolean
|
||
}
|
||
|
||
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
|
||
isDingtalkAdmin?: string | number | boolean | null
|
||
isDingtalkBoss?: string | number | boolean | null
|
||
isDingtalkSenior?: string | number | boolean | null
|
||
registerName?: string | null
|
||
flowerName?: string | null
|
||
realName?: string | null
|
||
registerJobNumber?: string | null
|
||
positionTitle?: string | null
|
||
positionLevel?: string | null
|
||
departmentName?: string | null
|
||
departmentPath?: string | null
|
||
registerDeptIds?: string | null
|
||
registerMainDeptId?: string | null
|
||
employmentStatus?: string | null
|
||
managerDingtalkUserId?: string | null
|
||
managerJobNumber?: string | null
|
||
managerName?: string | null
|
||
flowerBookName?: string | null
|
||
flowerBookFlowerName?: string | null
|
||
flowerBookDept?: string | null
|
||
flowerBookDeptId?: string | null
|
||
flowerBookWorkPlace?: string | null
|
||
flowerBookJob?: string | null
|
||
flowerBookJobLevel?: string | null
|
||
flowerBookRole?: string | null
|
||
flowerBookDirector?: string | null
|
||
flowerBookManager?: string | null
|
||
flowerBookEmployeeStatus?: string | null
|
||
groupDepartmentName?: string | null
|
||
groupDepartmentPath?: string | null
|
||
resolvedDepartments?: ResolvedEmployeeDepartment[]
|
||
}
|
||
|
||
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 EmployeeProfileSnapshot = {
|
||
id: string
|
||
dingtalkUserId?: string | null
|
||
dingEmail?: string | null
|
||
jobNumber?: string | null
|
||
flowerName?: string | null
|
||
realName?: string | null
|
||
displayName: string
|
||
departmentName?: string | null
|
||
departmentPath?: string | null
|
||
positionTitle?: string | null
|
||
positionLevel?: string | null
|
||
levelType?: string | null
|
||
isLeadership: boolean
|
||
isDingtalkAdmin?: boolean
|
||
isDingtalkBoss?: boolean
|
||
isDingtalkSenior?: boolean
|
||
managerDingtalkUserId?: string | null
|
||
managerJobNumber?: string | null
|
||
managerName?: string | null
|
||
employmentStatus?: string | null
|
||
workPlace?: string | null
|
||
sourcePayload?: unknown
|
||
syncedAt?: Date | null
|
||
departmentMemberships?: ResolvedEmployeeDepartment[]
|
||
}
|
||
|
||
type TableColumns = Map<string, string>
|
||
|
||
const EMPLOYEE_TABLE = {
|
||
schema: process.env.REPORT_DB_EMPLOYEE_SCHEMA || 'rpt',
|
||
table: process.env.REPORT_DB_EMPLOYEE_TABLE || 'hr_employee',
|
||
}
|
||
|
||
const REGISTER_TABLE = {
|
||
schema: process.env.REPORT_DB_REGISTER_SCHEMA || 'rpt',
|
||
table: process.env.REPORT_DB_REGISTER_TABLE || 'hr_employee_register',
|
||
}
|
||
|
||
const FLOWER_TABLE = {
|
||
schema: process.env.REPORT_DB_FLOWER_NAME_SCHEMA || 'rpt',
|
||
table: process.env.REPORT_DB_FLOWER_NAME_TABLE || 'hr_flowerName_book',
|
||
}
|
||
|
||
const GROUP_TABLE = {
|
||
schema: process.env.REPORT_DB_GROUP_SCHEMA || 'rpt',
|
||
table: process.env.REPORT_DB_GROUP_TABLE || 'hr_groups',
|
||
}
|
||
|
||
|
||
/**
|
||
* 在职人员的查询条件。
|
||
*
|
||
* 改为依据规范化的 employmentState 判定。此前是拿「离职/停用/退休」对
|
||
* employmentStatus 做包含匹配,而上游状态口径混杂——既有数字码(2 试用 /
|
||
* 3 正式 / 5 已离职),也有中文,还有大量空值,关键词匹配对前两类完全失效:
|
||
* 核实时 216 条已离职档案中有 215 条被判定为在职。
|
||
*
|
||
* employmentState 由 lib/organization-sync.ts 依据上游离职表与状态码同步维护。
|
||
* UNKNOWN 一并视为在职:这些人上游查不到明确状态,宁可多留也不误删——
|
||
* 少判导致的是"人凭空消失",比多出几个人难发现得多。
|
||
*/
|
||
export function getActivePerformanceSurveyEmployeeProfileWhere() {
|
||
return {
|
||
OR: [
|
||
{ employmentState: 'ACTIVE' },
|
||
{ employmentState: 'UNKNOWN' },
|
||
{ employmentState: null },
|
||
],
|
||
} as any
|
||
}
|
||
|
||
const FIELD_CANDIDATES = {
|
||
dingtalkUserId: ['userid', 'user_id', 'ding_userid', 'dingtalk_userid', 'dingtalk_user_id', 'userId'],
|
||
email: ['email', 'org_email', 'work_email', 'mail', '邮箱'],
|
||
jobNumber: ['job_number', 'jobNumber', 'job_no', 'employee_no', '工号'],
|
||
name: ['name', 'real_name', 'realName', 'username', '姓名'],
|
||
title: ['title', 'position', 'job_title', '岗位'],
|
||
workPlace: ['work_place', 'workPlace', 'workplace', 'office_location', '办公地点'],
|
||
extension: ['extension', 'ext', 'extattr', 'ext_attr'],
|
||
active: ['active', 'is_active', 'enabled'],
|
||
admin: ['admin', 'is_admin', 'administrator'],
|
||
boss: ['boss', 'is_boss'],
|
||
senior: ['senior', 'is_senior'],
|
||
employeeDeptIdList: ['dept_id_list', 'deptIdList', 'department_id_list', '部门id列表'],
|
||
registerName: ['sys00-name', 'name', '姓名'],
|
||
flowerName: ['sys00-flowerName', 'flower_name', 'flowerName', '花名'],
|
||
registerRealName: ['sys02-realName', 'real_name', 'realName', '真实姓名'],
|
||
registerJobNumber: ['sys00-jobNumber', 'job_number', 'jobNumber', '工号'],
|
||
positionTitle: ['sys00-position', 'position', 'title', '岗位'],
|
||
positionLevel: ['sys01-positionLevel', 'position_level', 'positionLevel', '岗位职级', '职级'],
|
||
departmentName: ['sys00-dept', 'department', 'department_name', 'dept_name', '部门'],
|
||
departmentPath: ['sys00-mainDepts', 'department_path', 'main_departments', '部门路径'],
|
||
registerDeptIds: ['sys00-deptIds', 'dept_ids', 'department_ids', '部门ids'],
|
||
registerMainDeptId: ['sys00-mainDeptId', 'main_dept_id', 'mainDepartmentId', '主部门id'],
|
||
employmentStatus: ['sys01-employeeStatus', 'employee_status', 'employment_status', '在职状态'],
|
||
managerDingtalkUserId: ['manager_userid', 'managerUserid', 'leader_userid', '主管userid'],
|
||
managerJobNumber: ['manager_job_number', 'managerJobNumber', 'leader_job_number', '主管工号'],
|
||
managerName: ['manager_name', 'managerName', 'leader_name', '主管'],
|
||
flowerBookJobNumber: ['jobNum', 'job_number', 'jobNumber', '工号'],
|
||
flowerBookName: ['name', '姓名'],
|
||
flowerBookFlowerName: ['flowerName', 'flower_name', '花名'],
|
||
flowerBookDept: ['dept', 'department', 'department_name', '部门'],
|
||
flowerBookDeptId: ['deptId', 'dept_id', 'department_id', '部门id'],
|
||
flowerBookWorkPlace: ['workPlace', 'work_place', '办公地点'],
|
||
flowerBookJob: ['job', 'position', '岗位'],
|
||
flowerBookJobLevel: ['jobLevel', 'job_level', '岗位职级', '职级'],
|
||
flowerBookRole: ['role', '角色'],
|
||
flowerBookDirector: ['director', 'directorName', '主管'],
|
||
flowerBookManager: ['manager', 'managerName', '经理'],
|
||
flowerBookEmployeeStatus: ['empStatus', 'employee_status', '在职状态'],
|
||
flowerBookStatus: ['status'],
|
||
flowerBookLeaveDate: ['leaveDate', 'leave_date', '离职日期'],
|
||
}
|
||
|
||
let dailyWorkerStarted = false
|
||
|
||
function normalizeEmployeeSyncLimit(value: unknown, fallback = 3000) {
|
||
const parsed = Number(value)
|
||
return Math.max(1, Math.min(Number.isFinite(parsed) ? parsed : fallback, 10_000))
|
||
}
|
||
|
||
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 normalizeBoolean(value: unknown) {
|
||
if (typeof value === 'boolean') return value
|
||
if (typeof value === 'number') return value === 1
|
||
const normalized = clean(value)?.toLowerCase()
|
||
return normalized === '1'
|
||
|| normalized === 'true'
|
||
|| normalized === 'yes'
|
||
|| normalized === 'y'
|
||
|| normalized === '是'
|
||
}
|
||
|
||
const ACCOUNT_PROFILE_WHERE_CACHE_MS = Math.max(
|
||
5,
|
||
Number(process.env.PERFORMANCE_SURVEY_ACCOUNT_PROFILE_WHERE_CACHE_SECONDS || 60),
|
||
) * 1000
|
||
const PROFILE_BACKGROUND_REFRESH_MIN_MS = Math.max(
|
||
10,
|
||
Number(process.env.PERFORMANCE_SURVEY_PROFILE_BACKGROUND_REFRESH_MIN_SECONDS || 60),
|
||
) * 1000
|
||
const PROFILE_BACKGROUND_REFRESH_RETENTION_MS = Math.max(PROFILE_BACKGROUND_REFRESH_MIN_MS * 10, 10 * 60 * 1000)
|
||
const PROFILE_BACKGROUND_REFRESH_MAX_IN_FLIGHT = 32
|
||
const PROFILE_BACKGROUND_REFRESH_MAX_HISTORY = 1024
|
||
|
||
let accountProfileWhereCache: { expiresAt: number; where: any } | null = null
|
||
let accountProfileWhereBuildPromise: Promise<any> | null = null
|
||
const profileRefreshInFlight = new Map<string, Promise<unknown>>()
|
||
const profileRefreshStartedAt = new Map<string, number>()
|
||
|
||
function pruneProfileRefreshTracking(now = Date.now()) {
|
||
for (const [key, startedAt] of Array.from(profileRefreshStartedAt.entries())) {
|
||
if (!profileRefreshInFlight.has(key) && now - startedAt >= PROFILE_BACKGROUND_REFRESH_RETENTION_MS) {
|
||
profileRefreshStartedAt.delete(key)
|
||
}
|
||
}
|
||
|
||
while (profileRefreshStartedAt.size > PROFILE_BACKGROUND_REFRESH_MAX_HISTORY) {
|
||
const oldestKey = Array.from(profileRefreshStartedAt.keys()).find((key) => !profileRefreshInFlight.has(key))
|
||
if (!oldestKey) break
|
||
profileRefreshStartedAt.delete(oldestKey)
|
||
}
|
||
}
|
||
|
||
export function invalidatePerformanceSurveyAccountProfileWhereCache() {
|
||
accountProfileWhereCache = null
|
||
accountProfileWhereBuildPromise = null
|
||
}
|
||
|
||
function parseExtension(value?: string | null) {
|
||
if (!value) return {}
|
||
try {
|
||
const parsed = JSON.parse(value)
|
||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||
? (parsed as Record<string, unknown>)
|
||
: {}
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
function parseDepartmentValue(value?: string | null) {
|
||
const parts = parseDepartmentParts(value)
|
||
if (parts.length > 0) return parts[parts.length - 1] || parts[0] || null
|
||
|
||
const normalized = clean(value)
|
||
if (!normalized) return null
|
||
|
||
return normalized
|
||
}
|
||
|
||
function parseDepartmentParts(value?: string | null) {
|
||
const normalized = clean(value)
|
||
if (!normalized) return []
|
||
|
||
if (normalized.startsWith('[')) {
|
||
try {
|
||
const parsed = JSON.parse(normalized)
|
||
if (Array.isArray(parsed)) {
|
||
return parsed.map((item) => clean(item)).filter((item): item is string => Boolean(item))
|
||
}
|
||
} catch {
|
||
// 继续按普通字符串拆分,兼容报表库里偶发的非标准 JSON 文本。
|
||
}
|
||
}
|
||
|
||
return normalized
|
||
.split(/[/>||,,;;]/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function getDepartmentParts(row: ReportEmployeeRow) {
|
||
const groupParts = parseDepartmentParts(row.groupDepartmentPath)
|
||
if (groupParts.length > 0) return groupParts
|
||
|
||
const flowerBookParts = parseDepartmentParts(row.flowerBookDept)
|
||
if (flowerBookParts.length > 0) return flowerBookParts
|
||
|
||
const pathParts = parseDepartmentParts(row.departmentPath)
|
||
const nameParts = parseDepartmentParts(row.departmentName)
|
||
if (pathParts.length === 0) return nameParts
|
||
if (nameParts.length === 0) return pathParts
|
||
|
||
const missingNameParts = nameParts.filter((part) => !pathParts.includes(part))
|
||
return missingNameParts.length > 0 ? [...missingNameParts, ...pathParts] : pathParts
|
||
}
|
||
|
||
function joinDepartmentParts(parts: string[]) {
|
||
return parts.length > 0 ? parts.join('/') : null
|
||
}
|
||
|
||
function getDepartmentLeaf(parts: string[]) {
|
||
return parts.length > 0 ? parts[parts.length - 1] || parts[0] || null : null
|
||
}
|
||
|
||
function parseDepartmentIdParts(value?: string | null) {
|
||
const normalized = clean(value)
|
||
if (!normalized) return []
|
||
|
||
if (normalized.startsWith('[')) {
|
||
try {
|
||
const parsed = JSON.parse(normalized)
|
||
if (Array.isArray(parsed)) {
|
||
return parsed.map((item) => clean(item)).filter((item): item is string => Boolean(item))
|
||
}
|
||
} catch {
|
||
// 继续按普通分隔符拆分。
|
||
}
|
||
}
|
||
|
||
return normalized
|
||
.split(/[/>||,,;;\s]+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function normalizeDepartmentId(value: unknown) {
|
||
const normalized = clean(value)
|
||
if (!normalized) return null
|
||
|
||
const numeric = Number(normalized)
|
||
return Number.isInteger(numeric) && numeric > 0 ? String(numeric) : normalized
|
||
}
|
||
|
||
function getFlowerBookDepartmentId(row: ReportEmployeeRow) {
|
||
const ids = parseDepartmentIdParts(row.flowerBookDeptId)
|
||
return normalizeDepartmentId(ids[ids.length - 1])
|
||
}
|
||
|
||
function getPrimaryDepartmentId(row: ReportEmployeeRow) {
|
||
return (
|
||
normalizeDepartmentId(parseDepartmentIdParts(row.registerMainDeptId)[0]) ||
|
||
normalizeDepartmentId(parseDepartmentIdParts(row.registerDeptIds)[0]) ||
|
||
getFlowerBookDepartmentId(row) ||
|
||
normalizeDepartmentId(parseDepartmentIdParts(row.employeeDeptIdList)[0]) ||
|
||
null
|
||
)
|
||
}
|
||
|
||
function getAllDepartmentIds(row: ReportEmployeeRow) {
|
||
const primaryDepartmentId = getPrimaryDepartmentId(row)
|
||
const ids = [
|
||
primaryDepartmentId,
|
||
...parseDepartmentIdParts(row.registerDeptIds).map(normalizeDepartmentId),
|
||
...parseDepartmentIdParts(row.employeeDeptIdList).map(normalizeDepartmentId),
|
||
getFlowerBookDepartmentId(row),
|
||
].filter((id): id is string => Boolean(id))
|
||
|
||
return Array.from(new Set(ids))
|
||
}
|
||
|
||
async function resolveDepartmentPathsByIds(pool: any, ids: string[]) {
|
||
const numericIds = Array.from(
|
||
new Set(
|
||
ids
|
||
.map((id) => Number(id))
|
||
.filter((id) => Number.isInteger(id) && id > 0),
|
||
),
|
||
)
|
||
if (numericIds.length === 0) return new Map<string, string>()
|
||
|
||
const valuesSql = numericIds.map((_, index) => `(@dept${index})`).join(', ')
|
||
const request = pool.request()
|
||
numericIds.forEach((id, index) => request.input(`dept${index}`, id))
|
||
|
||
const result = await request.query(`
|
||
;WITH input_depts(dept_id) AS (
|
||
SELECT value FROM (VALUES ${valuesSql}) AS v(value)
|
||
),
|
||
dept_tree AS (
|
||
SELECT
|
||
input_depts.dept_id,
|
||
g.Id,
|
||
g.parent_id,
|
||
CAST(g.name AS NVARCHAR(MAX)) AS path,
|
||
0 AS depth
|
||
FROM input_depts
|
||
JOIN ${quoteIdentifier(GROUP_TABLE.schema)}.${quoteIdentifier(GROUP_TABLE.table)} g ON g.Id = input_depts.dept_id
|
||
UNION ALL
|
||
SELECT
|
||
dept_tree.dept_id,
|
||
parent.Id,
|
||
parent.parent_id,
|
||
CAST(parent.name + N'/' + dept_tree.path AS NVARCHAR(MAX)) AS path,
|
||
dept_tree.depth + 1 AS depth
|
||
FROM dept_tree
|
||
JOIN ${quoteIdentifier(GROUP_TABLE.schema)}.${quoteIdentifier(GROUP_TABLE.table)} parent ON parent.Id = dept_tree.parent_id
|
||
WHERE dept_tree.depth < 20
|
||
),
|
||
ranked AS (
|
||
SELECT
|
||
dept_id,
|
||
path,
|
||
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY depth DESC) AS rn
|
||
FROM dept_tree
|
||
)
|
||
SELECT dept_id, path
|
||
FROM ranked
|
||
WHERE rn = 1
|
||
OPTION (MAXRECURSION 50)
|
||
`)
|
||
|
||
return new Map(
|
||
(result.recordset || [])
|
||
.map((row: { dept_id: number; path: unknown }) => [
|
||
String(row.dept_id),
|
||
clean(row.path),
|
||
] as const)
|
||
.filter((item: readonly [string, string | null]): item is readonly [string, string] => Boolean(item[1])),
|
||
)
|
||
}
|
||
|
||
async function applyResolvedDepartmentPaths(pool: any, rows: ReportEmployeeRow[]) {
|
||
const ids = rows.flatMap((row) => getAllDepartmentIds(row))
|
||
if (ids.length === 0) return rows
|
||
|
||
const pathById = await resolveDepartmentPathsByIds(pool, ids)
|
||
return rows.map((row) => {
|
||
const primaryDepartmentId = getPrimaryDepartmentId(row)
|
||
const fallbackParts = getDepartmentParts(row)
|
||
const fallbackPath = joinDepartmentParts(fallbackParts)
|
||
const fallbackName = getDepartmentLeaf(fallbackParts)
|
||
const resolvedDepartments = getAllDepartmentIds(row).map((sourceDeptId) => {
|
||
const resolvedPath = clean(pathById.get(sourceDeptId))
|
||
const isPrimary = sourceDeptId === primaryDepartmentId
|
||
const departmentPath = resolvedPath || (isPrimary ? fallbackPath : null)
|
||
const parts = parseDepartmentParts(departmentPath)
|
||
return {
|
||
sourceDeptId,
|
||
departmentName: getDepartmentLeaf(parts) || (isPrimary ? fallbackName : null),
|
||
departmentPath,
|
||
isPrimary,
|
||
}
|
||
})
|
||
const primaryDepartment = resolvedDepartments.find((item) => item.isPrimary)
|
||
if (!primaryDepartment?.departmentPath) {
|
||
return {
|
||
...row,
|
||
resolvedDepartments,
|
||
}
|
||
}
|
||
|
||
return {
|
||
...row,
|
||
groupDepartmentName: primaryDepartment.departmentName,
|
||
groupDepartmentPath: primaryDepartment.departmentPath,
|
||
resolvedDepartments,
|
||
}
|
||
})
|
||
}
|
||
|
||
function getLevelType(positionLevel?: string | null) {
|
||
const first = clean(positionLevel)?.slice(0, 1).toUpperCase()
|
||
if (first === 'P' || first === 'M') return first
|
||
return null
|
||
}
|
||
|
||
function isLeadershipLevel(positionLevel?: string | null) {
|
||
return getLevelType(positionLevel) === 'M'
|
||
}
|
||
|
||
function quoteIdentifier(value: string) {
|
||
return `[${value.replace(/]/g, ']]')}]`
|
||
}
|
||
|
||
function pickColumn(columns: TableColumns, candidates: string[]) {
|
||
for (const candidate of candidates) {
|
||
const matched = columns.get(candidate.toLowerCase())
|
||
if (matched) return matched
|
||
}
|
||
return null
|
||
}
|
||
|
||
function columnExpr(alias: string, column: string | null, cast = 'nvarchar(4000)') {
|
||
return column ? `${alias}.${quoteIdentifier(column)}` : `CAST(NULL AS ${cast})`
|
||
}
|
||
|
||
function eqCondition(alias: string, column: string | null, paramName: string) {
|
||
return column ? `${alias}.${quoteIdentifier(column)} = @${paramName}` : null
|
||
}
|
||
|
||
function notInactiveStatusCondition(alias: string, column: string | null) {
|
||
if (!column) return null
|
||
const expr = `${alias}.${quoteIdentifier(column)}`
|
||
return `(${expr} IS NULL OR ${expr} NOT LIKE N'%离职%')`
|
||
}
|
||
|
||
function emptyDateCondition(alias: string, column: string | null) {
|
||
return column ? `${alias}.${quoteIdentifier(column)} IS NULL` : null
|
||
}
|
||
|
||
function getFlowerBookActiveConditions(
|
||
alias: string,
|
||
flowerColumns: {
|
||
status?: string | null
|
||
employeeStatus?: string | null
|
||
leaveDate?: string | null
|
||
},
|
||
) {
|
||
return [
|
||
notInactiveStatusCondition(alias, flowerColumns.status || null),
|
||
notInactiveStatusCondition(alias, flowerColumns.employeeStatus || null),
|
||
emptyDateCondition(alias, flowerColumns.leaveDate || null),
|
||
].filter((item): item is string => Boolean(item))
|
||
}
|
||
|
||
async function getTableColumns(pool: any, schema: string, table: string): Promise<TableColumns> {
|
||
const result = await pool
|
||
.request()
|
||
.input('schema', schema)
|
||
.input('table', table)
|
||
.query(`
|
||
SELECT COLUMN_NAME
|
||
FROM INFORMATION_SCHEMA.COLUMNS
|
||
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table
|
||
`)
|
||
|
||
return new Map(
|
||
((result.recordset || []) as Array<{ COLUMN_NAME: string }>).map((row) => [
|
||
String(row.COLUMN_NAME).toLowerCase(),
|
||
row.COLUMN_NAME,
|
||
]),
|
||
)
|
||
}
|
||
|
||
async function getReportSchema(pool: any) {
|
||
const employeeColumns = await getTableColumns(pool, EMPLOYEE_TABLE.schema, EMPLOYEE_TABLE.table)
|
||
const registerColumns = await getTableColumns(pool, REGISTER_TABLE.schema, REGISTER_TABLE.table)
|
||
const flowerColumns = await getTableColumns(pool, FLOWER_TABLE.schema, FLOWER_TABLE.table)
|
||
|
||
if (employeeColumns.size === 0) {
|
||
throw new Error(`报表库未找到人员表 ${EMPLOYEE_TABLE.schema}.${EMPLOYEE_TABLE.table}`)
|
||
}
|
||
|
||
return { employeeColumns, registerColumns, flowerColumns }
|
||
}
|
||
|
||
function buildJoinCondition(employeeColumns: TableColumns, registerColumns: TableColumns) {
|
||
const employeeUserId = pickColumn(employeeColumns, FIELD_CANDIDATES.dingtalkUserId)
|
||
const registerUserId = pickColumn(registerColumns, FIELD_CANDIDATES.dingtalkUserId)
|
||
const employeeJobNumber = pickColumn(employeeColumns, FIELD_CANDIDATES.jobNumber)
|
||
const registerJobNumber = pickColumn(registerColumns, FIELD_CANDIDATES.registerJobNumber)
|
||
const conditions = [
|
||
employeeUserId && registerUserId
|
||
? `r.${quoteIdentifier(registerUserId)} = e.${quoteIdentifier(employeeUserId)}`
|
||
: null,
|
||
employeeJobNumber && registerJobNumber
|
||
? `r.${quoteIdentifier(registerJobNumber)} = e.${quoteIdentifier(employeeJobNumber)}`
|
||
: null,
|
||
].filter(Boolean)
|
||
|
||
return conditions.length > 0 ? conditions.join(' OR ') : '1 = 0'
|
||
}
|
||
|
||
function buildSelectSql(
|
||
employeeColumns: TableColumns,
|
||
registerColumns: TableColumns,
|
||
flowerColumns: TableColumns,
|
||
whereSql: string,
|
||
limitSql = '@limit',
|
||
) {
|
||
const e = {
|
||
dingtalkUserId: pickColumn(employeeColumns, FIELD_CANDIDATES.dingtalkUserId),
|
||
email: pickColumn(employeeColumns, FIELD_CANDIDATES.email),
|
||
jobNumber: pickColumn(employeeColumns, FIELD_CANDIDATES.jobNumber),
|
||
name: pickColumn(employeeColumns, FIELD_CANDIDATES.name),
|
||
title: pickColumn(employeeColumns, FIELD_CANDIDATES.title),
|
||
workPlace: pickColumn(employeeColumns, FIELD_CANDIDATES.workPlace),
|
||
extension: pickColumn(employeeColumns, FIELD_CANDIDATES.extension),
|
||
active: pickColumn(employeeColumns, FIELD_CANDIDATES.active),
|
||
admin: pickColumn(employeeColumns, FIELD_CANDIDATES.admin),
|
||
boss: pickColumn(employeeColumns, FIELD_CANDIDATES.boss),
|
||
senior: pickColumn(employeeColumns, FIELD_CANDIDATES.senior),
|
||
deptIdList: pickColumn(employeeColumns, FIELD_CANDIDATES.employeeDeptIdList),
|
||
managerDingtalkUserId: pickColumn(employeeColumns, FIELD_CANDIDATES.managerDingtalkUserId),
|
||
managerJobNumber: pickColumn(employeeColumns, FIELD_CANDIDATES.managerJobNumber),
|
||
managerName: pickColumn(employeeColumns, FIELD_CANDIDATES.managerName),
|
||
}
|
||
const r = {
|
||
dingtalkUserId: pickColumn(registerColumns, FIELD_CANDIDATES.dingtalkUserId),
|
||
email: pickColumn(registerColumns, FIELD_CANDIDATES.email),
|
||
registerName: pickColumn(registerColumns, FIELD_CANDIDATES.registerName),
|
||
flowerName: pickColumn(registerColumns, FIELD_CANDIDATES.flowerName),
|
||
realName: pickColumn(registerColumns, FIELD_CANDIDATES.registerRealName),
|
||
jobNumber: pickColumn(registerColumns, FIELD_CANDIDATES.registerJobNumber),
|
||
positionTitle: pickColumn(registerColumns, FIELD_CANDIDATES.positionTitle),
|
||
positionLevel: pickColumn(registerColumns, FIELD_CANDIDATES.positionLevel),
|
||
departmentName: pickColumn(registerColumns, FIELD_CANDIDATES.departmentName),
|
||
departmentPath: pickColumn(registerColumns, FIELD_CANDIDATES.departmentPath),
|
||
deptIds: pickColumn(registerColumns, FIELD_CANDIDATES.registerDeptIds),
|
||
mainDeptId: pickColumn(registerColumns, FIELD_CANDIDATES.registerMainDeptId),
|
||
employmentStatus: pickColumn(registerColumns, FIELD_CANDIDATES.employmentStatus),
|
||
managerDingtalkUserId: pickColumn(registerColumns, FIELD_CANDIDATES.managerDingtalkUserId),
|
||
managerJobNumber: pickColumn(registerColumns, FIELD_CANDIDATES.managerJobNumber),
|
||
managerName: pickColumn(registerColumns, FIELD_CANDIDATES.managerName),
|
||
}
|
||
const f = {
|
||
jobNumber: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJobNumber),
|
||
name: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookName),
|
||
flowerName: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookFlowerName),
|
||
dept: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDept),
|
||
deptId: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDeptId),
|
||
workPlace: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookWorkPlace),
|
||
job: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJob),
|
||
jobLevel: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJobLevel),
|
||
role: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookRole),
|
||
director: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDirector),
|
||
manager: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookManager),
|
||
employeeStatus: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookEmployeeStatus),
|
||
status: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookStatus),
|
||
leaveDate: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookLeaveDate),
|
||
}
|
||
const activeFilter = e.active ? `AND (e.${quoteIdentifier(e.active)} = 1 OR e.${quoteIdentifier(e.active)} = '1')` : ''
|
||
const joinSql = registerColumns.size > 0
|
||
? `LEFT JOIN ${quoteIdentifier(REGISTER_TABLE.schema)}.${quoteIdentifier(REGISTER_TABLE.table)} r ON ${buildJoinCondition(employeeColumns, registerColumns)}`
|
||
: ''
|
||
const flowerJoinConditions = [
|
||
f.jobNumber && r.jobNumber ? `f.${quoteIdentifier(f.jobNumber)} = r.${quoteIdentifier(r.jobNumber)}` : null,
|
||
f.jobNumber && e.jobNumber ? `f.${quoteIdentifier(f.jobNumber)} = e.${quoteIdentifier(e.jobNumber)}` : null,
|
||
f.flowerName && r.flowerName ? `f.${quoteIdentifier(f.flowerName)} = r.${quoteIdentifier(r.flowerName)}` : null,
|
||
].filter(Boolean)
|
||
const flowerJoinSql = flowerColumns.size > 0 && flowerJoinConditions.length > 0
|
||
? `LEFT JOIN ${quoteIdentifier(FLOWER_TABLE.schema)}.${quoteIdentifier(FLOWER_TABLE.table)} f ON (${flowerJoinConditions.join(' OR ')})${getFlowerBookActiveConditions('f', f).map((condition) => ` AND ${condition}`).join('')}`
|
||
: ''
|
||
|
||
return `
|
||
SELECT TOP (${limitSql})
|
||
COALESCE(${columnExpr('e', e.dingtalkUserId)}, ${columnExpr('r', r.dingtalkUserId)}) AS "dingtalkUserId",
|
||
COALESCE(${columnExpr('e', e.email)}, ${columnExpr('r', r.email)}) AS "dingEmail",
|
||
${columnExpr('e', e.jobNumber)} AS "jobNumber",
|
||
${columnExpr('e', e.name)} AS "contactName",
|
||
${columnExpr('e', e.title)} AS "contactTitle",
|
||
${columnExpr('e', e.workPlace)} AS "workPlace",
|
||
${columnExpr('e', e.extension)} AS "extension",
|
||
${columnExpr('e', e.admin, 'bit')} AS "isDingtalkAdmin",
|
||
${columnExpr('e', e.boss, 'bit')} AS "isDingtalkBoss",
|
||
${columnExpr('e', e.senior, 'bit')} AS "isDingtalkSenior",
|
||
${columnExpr('e', e.deptIdList)} AS "employeeDeptIdList",
|
||
${columnExpr('r', r.registerName)} AS "registerName",
|
||
${columnExpr('r', r.flowerName)} AS "flowerName",
|
||
${columnExpr('r', r.realName)} AS "realName",
|
||
${columnExpr('r', r.jobNumber)} AS "registerJobNumber",
|
||
${columnExpr('r', r.positionTitle)} AS "positionTitle",
|
||
${columnExpr('r', r.positionLevel)} AS "positionLevel",
|
||
${columnExpr('r', r.departmentName)} AS "departmentName",
|
||
${columnExpr('r', r.departmentPath)} AS "departmentPath",
|
||
${columnExpr('r', r.deptIds)} AS "registerDeptIds",
|
||
${columnExpr('r', r.mainDeptId)} AS "registerMainDeptId",
|
||
${columnExpr('r', r.employmentStatus)} AS "employmentStatus",
|
||
COALESCE(${columnExpr('r', r.managerDingtalkUserId)}, ${columnExpr('e', e.managerDingtalkUserId)}) AS "managerDingtalkUserId",
|
||
COALESCE(${columnExpr('r', r.managerJobNumber)}, ${columnExpr('e', e.managerJobNumber)}) AS "managerJobNumber",
|
||
COALESCE(${columnExpr('r', r.managerName)}, ${columnExpr('e', e.managerName)}) AS "managerName",
|
||
${columnExpr('f', f.name)} AS "flowerBookName",
|
||
${columnExpr('f', f.flowerName)} AS "flowerBookFlowerName",
|
||
${columnExpr('f', f.dept)} AS "flowerBookDept",
|
||
${columnExpr('f', f.deptId)} AS "flowerBookDeptId",
|
||
${columnExpr('f', f.workPlace)} AS "flowerBookWorkPlace",
|
||
${columnExpr('f', f.job)} AS "flowerBookJob",
|
||
${columnExpr('f', f.jobLevel)} AS "flowerBookJobLevel",
|
||
${columnExpr('f', f.role)} AS "flowerBookRole",
|
||
${columnExpr('f', f.director)} AS "flowerBookDirector",
|
||
${columnExpr('f', f.manager)} AS "flowerBookManager",
|
||
${columnExpr('f', f.employeeStatus)} AS "flowerBookEmployeeStatus"
|
||
FROM ${quoteIdentifier(EMPLOYEE_TABLE.schema)}.${quoteIdentifier(EMPLOYEE_TABLE.table)} e
|
||
${joinSql}
|
||
${flowerJoinSql}
|
||
WHERE ${whereSql}
|
||
${activeFilter}
|
||
ORDER BY COALESCE(${columnExpr('r', r.flowerName)}, ${columnExpr('r', r.registerName)}, ${columnExpr('e', e.name)}, ${columnExpr('e', e.jobNumber)}) ASC
|
||
`
|
||
}
|
||
|
||
function buildEmployeeListSql(employeeColumns: TableColumns, whereSql?: string) {
|
||
const e = {
|
||
dingtalkUserId: pickColumn(employeeColumns, FIELD_CANDIDATES.dingtalkUserId),
|
||
email: pickColumn(employeeColumns, FIELD_CANDIDATES.email),
|
||
jobNumber: pickColumn(employeeColumns, FIELD_CANDIDATES.jobNumber),
|
||
name: pickColumn(employeeColumns, FIELD_CANDIDATES.name),
|
||
title: pickColumn(employeeColumns, FIELD_CANDIDATES.title),
|
||
workPlace: pickColumn(employeeColumns, FIELD_CANDIDATES.workPlace),
|
||
extension: pickColumn(employeeColumns, FIELD_CANDIDATES.extension),
|
||
active: pickColumn(employeeColumns, FIELD_CANDIDATES.active),
|
||
admin: pickColumn(employeeColumns, FIELD_CANDIDATES.admin),
|
||
boss: pickColumn(employeeColumns, FIELD_CANDIDATES.boss),
|
||
senior: pickColumn(employeeColumns, FIELD_CANDIDATES.senior),
|
||
deptIdList: pickColumn(employeeColumns, FIELD_CANDIDATES.employeeDeptIdList),
|
||
managerDingtalkUserId: pickColumn(employeeColumns, FIELD_CANDIDATES.managerDingtalkUserId),
|
||
managerJobNumber: pickColumn(employeeColumns, FIELD_CANDIDATES.managerJobNumber),
|
||
managerName: pickColumn(employeeColumns, FIELD_CANDIDATES.managerName),
|
||
}
|
||
const whereParts = [
|
||
e.active ? `(e.${quoteIdentifier(e.active)} = 1 OR e.${quoteIdentifier(e.active)} = '1')` : null,
|
||
whereSql ? `(${whereSql})` : null,
|
||
].filter(Boolean)
|
||
const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''
|
||
|
||
return `
|
||
SELECT TOP (@limit)
|
||
${columnExpr('e', e.dingtalkUserId)} AS "dingtalkUserId",
|
||
${columnExpr('e', e.email)} AS "dingEmail",
|
||
${columnExpr('e', e.jobNumber)} AS "jobNumber",
|
||
${columnExpr('e', e.name)} AS "contactName",
|
||
${columnExpr('e', e.title)} AS "contactTitle",
|
||
${columnExpr('e', e.workPlace)} AS "workPlace",
|
||
${columnExpr('e', e.extension)} AS "extension",
|
||
${columnExpr('e', e.admin, 'bit')} AS "isDingtalkAdmin",
|
||
${columnExpr('e', e.boss, 'bit')} AS "isDingtalkBoss",
|
||
${columnExpr('e', e.senior, 'bit')} AS "isDingtalkSenior",
|
||
${columnExpr('e', e.deptIdList)} AS "employeeDeptIdList",
|
||
${columnExpr('e', e.managerDingtalkUserId)} AS "managerDingtalkUserId",
|
||
${columnExpr('e', e.managerJobNumber)} AS "managerJobNumber",
|
||
${columnExpr('e', e.managerName)} AS "managerName"
|
||
FROM ${quoteIdentifier(EMPLOYEE_TABLE.schema)}.${quoteIdentifier(EMPLOYEE_TABLE.table)} e
|
||
${whereClause}
|
||
ORDER BY COALESCE(${columnExpr('e', e.name)}, ${columnExpr('e', e.jobNumber)}, ${columnExpr('e', e.dingtalkUserId)}) ASC
|
||
`
|
||
}
|
||
|
||
function buildRegisterListSql(registerColumns: TableColumns, whereSql?: string) {
|
||
const r = {
|
||
dingtalkUserId: pickColumn(registerColumns, FIELD_CANDIDATES.dingtalkUserId),
|
||
email: pickColumn(registerColumns, FIELD_CANDIDATES.email),
|
||
registerName: pickColumn(registerColumns, FIELD_CANDIDATES.registerName),
|
||
flowerName: pickColumn(registerColumns, FIELD_CANDIDATES.flowerName),
|
||
realName: pickColumn(registerColumns, FIELD_CANDIDATES.registerRealName),
|
||
jobNumber: pickColumn(registerColumns, FIELD_CANDIDATES.registerJobNumber),
|
||
positionTitle: pickColumn(registerColumns, FIELD_CANDIDATES.positionTitle),
|
||
positionLevel: pickColumn(registerColumns, FIELD_CANDIDATES.positionLevel),
|
||
departmentName: pickColumn(registerColumns, FIELD_CANDIDATES.departmentName),
|
||
departmentPath: pickColumn(registerColumns, FIELD_CANDIDATES.departmentPath),
|
||
deptIds: pickColumn(registerColumns, FIELD_CANDIDATES.registerDeptIds),
|
||
mainDeptId: pickColumn(registerColumns, FIELD_CANDIDATES.registerMainDeptId),
|
||
employmentStatus: pickColumn(registerColumns, FIELD_CANDIDATES.employmentStatus),
|
||
managerDingtalkUserId: pickColumn(registerColumns, FIELD_CANDIDATES.managerDingtalkUserId),
|
||
managerJobNumber: pickColumn(registerColumns, FIELD_CANDIDATES.managerJobNumber),
|
||
managerName: pickColumn(registerColumns, FIELD_CANDIDATES.managerName),
|
||
}
|
||
|
||
const whereParts = [
|
||
notInactiveStatusCondition('r', r.employmentStatus),
|
||
whereSql ? `(${whereSql})` : null,
|
||
].filter(Boolean)
|
||
const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''
|
||
|
||
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
|
||
${whereClause}
|
||
ORDER BY COALESCE(${columnExpr('r', r.flowerName)}, ${columnExpr('r', r.registerName)}, ${columnExpr('r', r.jobNumber)}, ${columnExpr('r', r.dingtalkUserId)}) ASC
|
||
`
|
||
}
|
||
|
||
function buildFlowerListSql(flowerColumns: TableColumns, whereSql?: string) {
|
||
const f = {
|
||
jobNumber: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJobNumber),
|
||
name: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookName),
|
||
flowerName: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookFlowerName),
|
||
dept: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDept),
|
||
deptId: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDeptId),
|
||
workPlace: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookWorkPlace),
|
||
job: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJob),
|
||
jobLevel: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJobLevel),
|
||
role: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookRole),
|
||
director: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDirector),
|
||
manager: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookManager),
|
||
employeeStatus: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookEmployeeStatus),
|
||
status: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookStatus),
|
||
leaveDate: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookLeaveDate),
|
||
}
|
||
const whereParts = [
|
||
...getFlowerBookActiveConditions('f', f),
|
||
whereSql ? `(${whereSql})` : null,
|
||
].filter(Boolean)
|
||
const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''
|
||
|
||
return `
|
||
SELECT TOP (@limit)
|
||
${columnExpr('f', f.jobNumber)} AS "registerJobNumber",
|
||
${columnExpr('f', f.name)} AS "flowerBookName",
|
||
${columnExpr('f', f.flowerName)} AS "flowerBookFlowerName",
|
||
${columnExpr('f', f.dept)} AS "flowerBookDept",
|
||
${columnExpr('f', f.deptId)} AS "flowerBookDeptId",
|
||
${columnExpr('f', f.workPlace)} AS "flowerBookWorkPlace",
|
||
${columnExpr('f', f.job)} AS "flowerBookJob",
|
||
${columnExpr('f', f.jobLevel)} AS "flowerBookJobLevel",
|
||
${columnExpr('f', f.role)} AS "flowerBookRole",
|
||
${columnExpr('f', f.director)} AS "flowerBookDirector",
|
||
${columnExpr('f', f.manager)} AS "flowerBookManager",
|
||
${columnExpr('f', f.employeeStatus)} AS "flowerBookEmployeeStatus"
|
||
FROM ${quoteIdentifier(FLOWER_TABLE.schema)}.${quoteIdentifier(FLOWER_TABLE.table)} f
|
||
${whereClause}
|
||
ORDER BY COALESCE(${columnExpr('f', f.flowerName)}, ${columnExpr('f', f.name)}, ${columnExpr('f', f.jobNumber)}) ASC
|
||
`
|
||
}
|
||
|
||
function normalizedKey(value: unknown) {
|
||
return clean(value)?.toLowerCase() || null
|
||
}
|
||
|
||
function addToIndex<T>(index: Map<string, T>, key: unknown, value: T) {
|
||
const normalized = normalizedKey(key)
|
||
if (normalized && !index.has(normalized)) {
|
||
index.set(normalized, value)
|
||
}
|
||
}
|
||
|
||
function indexRows(rows: ReportEmployeeRow[], keys: Array<keyof ReportEmployeeRow>) {
|
||
const index = new Map<string, ReportEmployeeRow>()
|
||
for (const row of rows) {
|
||
for (const key of keys) {
|
||
addToIndex(index, row[key], row)
|
||
}
|
||
}
|
||
return index
|
||
}
|
||
|
||
function getSyncableUserWhere() {
|
||
return {
|
||
archivedAt: null,
|
||
NOT: [
|
||
{ status: 'archived' },
|
||
{ status: 'suspended' },
|
||
],
|
||
} as any
|
||
}
|
||
|
||
async function listMaterialAccountSyncIdentities(limit?: number) {
|
||
const users = await prisma.user.findMany({
|
||
where: getSyncableUserWhere(),
|
||
select: {
|
||
id: true,
|
||
email: true,
|
||
username: true,
|
||
realName: true,
|
||
dingtalkUserId: true,
|
||
department: true,
|
||
},
|
||
orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }],
|
||
...(limit ? { take: limit } : {}),
|
||
})
|
||
|
||
return users.map((user) => ({
|
||
userId: user.id,
|
||
email: user.email,
|
||
username: user.username,
|
||
realName: user.realName,
|
||
displayName: clean(user.realName) || clean(user.username),
|
||
flowerName: clean(user.realName) || clean(user.username),
|
||
dingtalkUserId: user.dingtalkUserId,
|
||
department: user.department,
|
||
}))
|
||
}
|
||
|
||
async function countMaterialAccountSyncIdentities() {
|
||
return prisma.user.count({ where: getSyncableUserWhere() })
|
||
}
|
||
|
||
export async function getPerformanceSurveyAccountProfileWhere() {
|
||
if (accountProfileWhereCache && accountProfileWhereCache.expiresAt > Date.now()) {
|
||
return accountProfileWhereCache.where
|
||
}
|
||
if (accountProfileWhereBuildPromise) {
|
||
return accountProfileWhereBuildPromise
|
||
}
|
||
|
||
accountProfileWhereBuildPromise = buildPerformanceSurveyAccountProfileWhere()
|
||
.then((where) => {
|
||
accountProfileWhereCache = {
|
||
where,
|
||
expiresAt: Date.now() + ACCOUNT_PROFILE_WHERE_CACHE_MS,
|
||
}
|
||
return where
|
||
})
|
||
.finally(() => {
|
||
accountProfileWhereBuildPromise = null
|
||
})
|
||
|
||
return accountProfileWhereBuildPromise
|
||
}
|
||
|
||
async function buildPerformanceSurveyAccountProfileWhere() {
|
||
const identities = await listMaterialAccountSyncIdentities()
|
||
const userIds = Array.from(new Set(identities.map((item) => clean(item.userId)).filter(Boolean))) as string[]
|
||
const dingtalkUserIds = Array.from(new Set(identities.map((item) => clean(item.dingtalkUserId)).filter(Boolean))) as string[]
|
||
const emails = Array.from(new Set(identities.map((item) => clean(item.email)).filter(Boolean))) as string[]
|
||
|
||
const or: any[] = []
|
||
if (dingtalkUserIds.length > 0) {
|
||
or.push({ dingtalkUserId: { in: dingtalkUserIds } })
|
||
}
|
||
if (emails.length > 0) {
|
||
or.push({ dingEmail: { in: emails } })
|
||
}
|
||
if (userIds.length > 0) {
|
||
for (const userId of userIds) {
|
||
or.push({ sourcePayload: { path: ['materialUserId'], equals: userId } })
|
||
or.push({ sourcePayload: { path: ['userId'], equals: userId } })
|
||
}
|
||
}
|
||
|
||
const accountWhere = or.length > 0 ? { OR: or } : { id: { in: [] } }
|
||
return { AND: [accountWhere, getActivePerformanceSurveyEmployeeProfileWhere()] }
|
||
}
|
||
|
||
function uniqueClean(values: unknown[]) {
|
||
return Array.from(new Set(values.map((item) => clean(item)).filter((item): item is string => Boolean(item))))
|
||
}
|
||
|
||
function getIdentityNames(identity: ReportIdentity) {
|
||
return uniqueClean([identity.flowerName, identity.displayName, identity.realName, identity.username])
|
||
}
|
||
|
||
function getIdentityMatchKeys(identity: ReportIdentity) {
|
||
return [
|
||
...uniqueClean([identity.dingtalkUserId]).map((item) => `ding:${item.toLowerCase()}`),
|
||
...uniqueClean([identity.email]).map((item) => `email:${item.toLowerCase()}`),
|
||
...getIdentityNames(identity).map((item) => `name:${item.toLowerCase()}`),
|
||
]
|
||
}
|
||
|
||
function getNormalizedProfileMatchKeys(profile: ReturnType<typeof normalizeEmployeeRow>) {
|
||
return [
|
||
...uniqueClean([profile.dingtalkUserId]).map((item) => `ding:${item.toLowerCase()}`),
|
||
...uniqueClean([profile.dingEmail]).map((item) => `email:${item.toLowerCase()}`),
|
||
...uniqueClean([profile.displayName, profile.flowerName, profile.realName]).map((item) => `name:${item.toLowerCase()}`),
|
||
]
|
||
}
|
||
|
||
function getIdentityProfileMatchScore(identity: ReportIdentity, profile: ReturnType<typeof normalizeEmployeeRow>) {
|
||
const identityDing = clean(identity.dingtalkUserId)?.toLowerCase()
|
||
const profileDing = clean(profile.dingtalkUserId)?.toLowerCase()
|
||
if (identityDing && profileDing && identityDing === profileDing) return 3
|
||
|
||
const identityEmail = clean(identity.email)?.toLowerCase()
|
||
const profileEmail = clean(profile.dingEmail)?.toLowerCase()
|
||
if (identityEmail && profileEmail && identityEmail === profileEmail) return 2
|
||
|
||
const profileNames = new Set(uniqueClean([profile.displayName, profile.flowerName, profile.realName]).map((item) => item.toLowerCase()))
|
||
return getIdentityNames(identity).some((name) => profileNames.has(name.toLowerCase())) ? 1 : 0
|
||
}
|
||
|
||
function getIndexed(index: Map<string, ReportEmployeeRow>, ...keys: unknown[]) {
|
||
for (const key of keys) {
|
||
const normalized = normalizedKey(key)
|
||
if (!normalized) continue
|
||
const matched = index.get(normalized)
|
||
if (matched) return matched
|
||
}
|
||
return null
|
||
}
|
||
|
||
function mergeReportRows(...rows: Array<ReportEmployeeRow | null | undefined>): ReportEmployeeRow {
|
||
const merged: ReportEmployeeRow = {}
|
||
for (const row of rows) {
|
||
if (!row) continue
|
||
for (const [key, value] of Object.entries(row) as Array<[keyof ReportEmployeeRow, unknown]>) {
|
||
if (clean(merged[key]) || clean(value) === null) continue
|
||
;(merged as Record<string, unknown>)[key] = value
|
||
}
|
||
}
|
||
return merged
|
||
}
|
||
|
||
async function queryReportRows(
|
||
pool: any,
|
||
sql: typeof import('mssql'),
|
||
query: string,
|
||
limit: number,
|
||
bindInputs?: (request: any) => void,
|
||
): Promise<ReportEmployeeRow[]> {
|
||
const request = pool.request().input('limit', sql.Int, limit)
|
||
bindInputs?.(request)
|
||
const result = await request.query(query)
|
||
return (result.recordset || []) as ReportEmployeeRow[]
|
||
}
|
||
|
||
async function fetchFullSyncRows(
|
||
pool: any,
|
||
sql: typeof import('mssql'),
|
||
schema: Awaited<ReturnType<typeof getReportSchema>>,
|
||
limit: number,
|
||
) {
|
||
const employeeRows = await queryReportRows(pool, sql, buildEmployeeListSql(schema.employeeColumns), limit)
|
||
const registerRows = schema.registerColumns.size > 0
|
||
? await queryReportRows(pool, sql, buildRegisterListSql(schema.registerColumns), limit)
|
||
: []
|
||
const flowerRows = schema.flowerColumns.size > 0
|
||
? await queryReportRows(pool, sql, buildFlowerListSql(schema.flowerColumns), Math.max(limit, Math.min(limit * 3, 10_000)))
|
||
: []
|
||
|
||
const registerByUserId = indexRows(registerRows, ['dingtalkUserId'])
|
||
const registerByJobNumber = indexRows(registerRows, ['registerJobNumber', 'jobNumber'])
|
||
const registerByName = indexRows(registerRows, ['flowerName', 'realName', 'registerName'])
|
||
const flowerByJobNumber = indexRows(flowerRows, ['registerJobNumber', 'jobNumber'])
|
||
const flowerByName = indexRows(flowerRows, ['flowerBookFlowerName', 'flowerBookName'])
|
||
|
||
const mergedRows: ReportEmployeeRow[] = []
|
||
const seen = new Set<string>()
|
||
const pushMerged = (row: ReportEmployeeRow) => {
|
||
const normalized = normalizeEmployeeRow(row)
|
||
const key = normalized.dingtalkUserId || normalized.jobNumber || normalized.dingEmail || normalized.displayName
|
||
if (seen.has(key)) return
|
||
seen.add(key)
|
||
mergedRows.push(row)
|
||
}
|
||
|
||
for (const employee of employeeRows) {
|
||
const register = getIndexed(registerByUserId, employee.dingtalkUserId)
|
||
|| getIndexed(registerByJobNumber, employee.jobNumber)
|
||
|| getIndexed(registerByName, employee.contactName)
|
||
const flower = getIndexed(flowerByJobNumber, register?.registerJobNumber, employee.jobNumber)
|
||
|| getIndexed(flowerByName, register?.flowerName, register?.realName, register?.registerName, employee.contactName)
|
||
pushMerged(mergeReportRows(employee, register, flower))
|
||
}
|
||
|
||
for (const register of registerRows) {
|
||
const flower = getIndexed(flowerByJobNumber, register.registerJobNumber, register.jobNumber)
|
||
|| getIndexed(flowerByName, register.flowerName, register.realName, register.registerName)
|
||
pushMerged(mergeReportRows(register, flower))
|
||
}
|
||
|
||
return applyResolvedDepartmentPaths(pool, mergedRows.slice(0, limit))
|
||
}
|
||
|
||
function buildIdentityWhere(
|
||
employeeColumns: TableColumns,
|
||
registerColumns: TableColumns,
|
||
flowerColumns: TableColumns,
|
||
identity: ReportIdentity,
|
||
) {
|
||
const conditions: string[] = []
|
||
const e = {
|
||
dingtalkUserId: pickColumn(employeeColumns, FIELD_CANDIDATES.dingtalkUserId),
|
||
email: pickColumn(employeeColumns, FIELD_CANDIDATES.email),
|
||
name: pickColumn(employeeColumns, FIELD_CANDIDATES.name),
|
||
}
|
||
const r = {
|
||
dingtalkUserId: pickColumn(registerColumns, FIELD_CANDIDATES.dingtalkUserId),
|
||
email: pickColumn(registerColumns, FIELD_CANDIDATES.email),
|
||
flowerName: pickColumn(registerColumns, FIELD_CANDIDATES.flowerName),
|
||
realName: pickColumn(registerColumns, FIELD_CANDIDATES.registerRealName),
|
||
registerName: pickColumn(registerColumns, FIELD_CANDIDATES.registerName),
|
||
}
|
||
const f = {
|
||
flowerName: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookFlowerName),
|
||
name: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookName),
|
||
}
|
||
|
||
if (clean(identity.dingtalkUserId)) {
|
||
conditions.push(
|
||
...[
|
||
eqCondition('e', e.dingtalkUserId, 'dingtalkUserId'),
|
||
eqCondition('r', r.dingtalkUserId, 'dingtalkUserId'),
|
||
].filter((item): item is string => Boolean(item)),
|
||
)
|
||
}
|
||
if (clean(identity.email)) {
|
||
conditions.push(
|
||
...[
|
||
eqCondition('e', e.email, 'email'),
|
||
eqCondition('r', r.email, 'email'),
|
||
].filter((item): item is string => Boolean(item)),
|
||
)
|
||
}
|
||
|
||
const nameParam = clean(identity.flowerName) || clean(identity.displayName) || clean(identity.realName) || clean(identity.username)
|
||
if (nameParam) {
|
||
conditions.push(
|
||
...[
|
||
eqCondition('r', r.flowerName, 'name'),
|
||
eqCondition('r', r.realName, 'name'),
|
||
eqCondition('r', r.registerName, 'name'),
|
||
eqCondition('e', e.name, 'name'),
|
||
eqCondition('f', f.flowerName, 'name'),
|
||
eqCondition('f', f.name, 'name'),
|
||
].filter((item): item is string => Boolean(item)),
|
||
)
|
||
}
|
||
|
||
return {
|
||
whereSql: conditions.length > 0 ? `(${conditions.join(' OR ')})` : '1 = 0',
|
||
nameParam,
|
||
}
|
||
}
|
||
|
||
function normalizeEmployeeRow(row: ReportEmployeeRow) {
|
||
const extension = parseExtension(row.extension)
|
||
const dingtalkUserId = clean(row.dingtalkUserId)
|
||
const dingEmail = clean(row.dingEmail)
|
||
const jobNumber = clean(row.registerJobNumber) || clean(row.jobNumber)
|
||
const flowerName = clean(row.flowerBookFlowerName) || clean(row.flowerName) || clean(extension['花名'])
|
||
const realName = clean(row.realName) || clean(row.flowerBookName) || clean(row.registerName) || clean(row.contactName)
|
||
const displayName = flowerName || realName || jobNumber || dingtalkUserId || dingEmail || '未命名员工'
|
||
const positionLevel = clean(row.flowerBookJobLevel) || clean(row.positionLevel) || clean(extension['岗位职级'])
|
||
const levelType = getLevelType(positionLevel)
|
||
const departmentParts = getDepartmentParts(row)
|
||
const departmentName = clean(row.groupDepartmentName) || getDepartmentLeaf(departmentParts) || parseDepartmentValue(row.flowerBookDept) || clean(row.departmentName)
|
||
const departmentPath = clean(row.groupDepartmentPath) || joinDepartmentParts(departmentParts) || clean(row.departmentPath)
|
||
const positionTitle = clean(row.flowerBookJob) || clean(row.positionTitle) || clean(row.contactTitle)
|
||
const managerName = clean(row.flowerBookManager) || clean(row.flowerBookDirector) || clean(row.managerName) || clean(extension['主管'])
|
||
const employmentStatus = clean(row.flowerBookEmployeeStatus) || clean(row.employmentStatus)
|
||
const workPlace = clean(row.flowerBookWorkPlace) || clean(row.workPlace)
|
||
const departmentMemberships = row.resolvedDepartments || []
|
||
const isDingtalkAdmin = normalizeBoolean(row.isDingtalkAdmin)
|
||
const isDingtalkBoss = normalizeBoolean(row.isDingtalkBoss)
|
||
const isDingtalkSenior = normalizeBoolean(row.isDingtalkSenior)
|
||
|
||
return {
|
||
dingtalkUserId,
|
||
dingEmail,
|
||
jobNumber,
|
||
flowerName,
|
||
realName,
|
||
displayName,
|
||
departmentName,
|
||
departmentPath,
|
||
positionTitle,
|
||
positionLevel,
|
||
levelType,
|
||
isLeadership: isLeadershipLevel(positionLevel),
|
||
isDingtalkAdmin,
|
||
isDingtalkBoss,
|
||
isDingtalkSenior,
|
||
managerDingtalkUserId: clean(row.managerDingtalkUserId) || clean(extension['主管userid']),
|
||
managerJobNumber: clean(row.managerJobNumber) || clean(extension['主管工号']),
|
||
managerName,
|
||
employmentStatus,
|
||
workPlace,
|
||
sourcePayload: {
|
||
source: 'JSReportReplica.rpt',
|
||
syncedFields: {
|
||
hasDingtalkUserId: Boolean(dingtalkUserId),
|
||
hasDingEmail: Boolean(dingEmail),
|
||
jobNumber,
|
||
displayName,
|
||
departmentName,
|
||
departmentPath,
|
||
primaryDeptId: getPrimaryDepartmentId(row),
|
||
departmentIds: departmentMemberships.map((item) => item.sourceDeptId),
|
||
employeeDeptIdList: clean(row.employeeDeptIdList),
|
||
registerDeptIds: clean(row.registerDeptIds),
|
||
registerMainDeptId: clean(row.registerMainDeptId),
|
||
flowerBookDeptId: clean(row.flowerBookDeptId),
|
||
positionLevel,
|
||
levelType,
|
||
flowerBookRole: clean(row.flowerBookRole),
|
||
isDingtalkAdmin,
|
||
isDingtalkBoss,
|
||
isDingtalkSenior,
|
||
},
|
||
},
|
||
departmentMemberships,
|
||
syncedAt: new Date(),
|
||
}
|
||
}
|
||
|
||
type PreviewProfileData = Partial<Omit<ReturnType<typeof normalizeEmployeeRow>, 'sourcePayload' | 'syncedAt'>> & {
|
||
sourcePayload?: unknown
|
||
syncedAt?: Date
|
||
}
|
||
|
||
const PREVIEW_PROFILE_FIELDS = [
|
||
'dingtalkUserId',
|
||
'dingEmail',
|
||
'jobNumber',
|
||
'flowerName',
|
||
'realName',
|
||
'displayName',
|
||
'departmentName',
|
||
'departmentPath',
|
||
'positionTitle',
|
||
'positionLevel',
|
||
'levelType',
|
||
'isLeadership',
|
||
'isDingtalkAdmin',
|
||
'isDingtalkBoss',
|
||
'isDingtalkSenior',
|
||
'managerDingtalkUserId',
|
||
'managerJobNumber',
|
||
'managerName',
|
||
'employmentStatus',
|
||
'workPlace',
|
||
] as const
|
||
type PreviewChangedField = (typeof PREVIEW_PROFILE_FIELDS)[number] | 'sourcePayload' | 'departmentMemberships'
|
||
|
||
function getSourcePayloadLink(payload: unknown) {
|
||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||
return { source: null, materialUserId: null, materialUserEmail: null }
|
||
}
|
||
const record = payload as Record<string, unknown>
|
||
return {
|
||
source: clean(record.source),
|
||
materialUserId: clean(record.materialUserId) || clean(record.userId),
|
||
materialUserEmail: clean(record.materialUserEmail),
|
||
}
|
||
}
|
||
|
||
function mergeExistingMaterialAccountLink(payload: unknown, existingPayload?: unknown) {
|
||
const merged = payload && typeof payload === 'object' && !Array.isArray(payload)
|
||
? { ...(payload as Record<string, unknown>) }
|
||
: {}
|
||
const incomingLink = getSourcePayloadLink(merged)
|
||
const existingLink = getSourcePayloadLink(existingPayload)
|
||
|
||
if (!incomingLink.materialUserId && existingLink.materialUserId) {
|
||
merged.materialUserId = existingLink.materialUserId
|
||
}
|
||
if (!incomingLink.materialUserEmail && existingLink.materialUserEmail) {
|
||
merged.materialUserEmail = existingLink.materialUserEmail
|
||
}
|
||
|
||
return merged
|
||
}
|
||
|
||
function getNormalizedProfileSyncKey(profile: ReturnType<typeof normalizeEmployeeRow>) {
|
||
return clean(profile.dingtalkUserId)?.toLowerCase()
|
||
|| clean(profile.jobNumber)?.toLowerCase()
|
||
|| clean(profile.dingEmail)?.toLowerCase()
|
||
|| `name:${clean(profile.displayName)?.toLowerCase() || 'unknown'}`
|
||
}
|
||
|
||
function previewValueEquals(left: unknown, right: unknown) {
|
||
if (typeof left === 'boolean' || typeof right === 'boolean') {
|
||
return Boolean(left) === Boolean(right)
|
||
}
|
||
return clean(left) === clean(right)
|
||
}
|
||
|
||
function normalizePreviewDepartmentMemberships(value: unknown) {
|
||
if (!Array.isArray(value)) return []
|
||
|
||
return value
|
||
.map((item) => {
|
||
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
|
||
const record = item as Record<string, unknown>
|
||
const sourceDeptId = normalizeDepartmentId(record.sourceDeptId)
|
||
if (!sourceDeptId) return null
|
||
return {
|
||
sourceDeptId,
|
||
departmentName: clean(record.departmentName),
|
||
departmentPath: clean(record.departmentPath),
|
||
isPrimary: Boolean(record.isPrimary),
|
||
}
|
||
})
|
||
.filter((item): item is ResolvedEmployeeDepartment => Boolean(item))
|
||
.sort((left, right) => left.sourceDeptId.localeCompare(right.sourceDeptId))
|
||
}
|
||
|
||
function hasBusinessProfileChanges(existing: EmployeeProfileSnapshot, proposed: PreviewProfileData) {
|
||
const changedFields: PreviewChangedField[] = PREVIEW_PROFILE_FIELDS.filter((field) => (
|
||
Object.prototype.hasOwnProperty.call(proposed, field)
|
||
&& !previewValueEquals(existing[field], proposed[field])
|
||
))
|
||
|
||
if (Object.prototype.hasOwnProperty.call(proposed, 'sourcePayload')) {
|
||
const currentLink = getSourcePayloadLink(existing.sourcePayload)
|
||
const proposedLink = getSourcePayloadLink(proposed.sourcePayload)
|
||
if (
|
||
currentLink.source !== proposedLink.source
|
||
|| currentLink.materialUserId !== proposedLink.materialUserId
|
||
|| currentLink.materialUserEmail !== proposedLink.materialUserEmail
|
||
) {
|
||
changedFields.push('sourcePayload')
|
||
}
|
||
}
|
||
|
||
if (Object.prototype.hasOwnProperty.call(proposed, 'departmentMemberships')) {
|
||
const currentDepartments = normalizePreviewDepartmentMemberships(existing.departmentMemberships)
|
||
const proposedDepartments = normalizePreviewDepartmentMemberships(proposed.departmentMemberships)
|
||
if (JSON.stringify(currentDepartments) !== JSON.stringify(proposedDepartments)) {
|
||
changedFields.push('departmentMemberships')
|
||
}
|
||
}
|
||
|
||
return changedFields
|
||
}
|
||
|
||
function getExistingProfileMatchKeys(profile: Partial<EmployeeProfileSnapshot>) {
|
||
return [
|
||
...uniqueClean([profile.dingtalkUserId]).map((value) => `ding:${value.toLowerCase()}`),
|
||
...uniqueClean([profile.jobNumber]).map((value) => `job:${value.toLowerCase()}`),
|
||
...uniqueClean([profile.dingEmail]).map((value) => `email:${value.toLowerCase()}`),
|
||
]
|
||
}
|
||
|
||
function getProposedProfileMatchKeys(profile: PreviewProfileData) {
|
||
return [
|
||
...uniqueClean([profile.dingtalkUserId]).map((value) => `ding:${value.toLowerCase()}`),
|
||
...uniqueClean([profile.jobNumber]).map((value) => `job:${value.toLowerCase()}`),
|
||
...uniqueClean([profile.dingEmail]).map((value) => `email:${value.toLowerCase()}`),
|
||
]
|
||
}
|
||
|
||
function buildFallbackPreviewProfile(identity: ReportIdentity & { userId?: string }): PreviewProfileData {
|
||
return {
|
||
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),
|
||
departmentName: clean(identity.department),
|
||
departmentPath: clean(identity.department),
|
||
sourcePayload: {
|
||
source: 'material_user_fallback',
|
||
userId: identity.userId || null,
|
||
materialUserId: identity.userId || null,
|
||
materialUserEmail: clean(identity.email),
|
||
},
|
||
}
|
||
}
|
||
|
||
function matchNormalizedRowsToIdentities(
|
||
identities: Array<ReportIdentity & { userId?: string }>,
|
||
rows: ReportEmployeeRow[],
|
||
) {
|
||
const identityIndexesByKey = new Map<string, number[]>()
|
||
identities.forEach((identity, index) => {
|
||
for (const key of getIdentityMatchKeys(identity)) {
|
||
const indexes = identityIndexesByKey.get(key) || []
|
||
indexes.push(index)
|
||
identityIndexesByKey.set(key, indexes)
|
||
}
|
||
})
|
||
|
||
let skippedRows = 0
|
||
const bestMatchesByIdentity = new Map<number, {
|
||
normalized: ReturnType<typeof normalizeEmployeeRow>
|
||
score: number
|
||
}>()
|
||
|
||
for (const row of rows) {
|
||
const normalized = normalizeEmployeeRow(row)
|
||
if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) {
|
||
skippedRows += 1
|
||
continue
|
||
}
|
||
|
||
const candidateIndexes = new Set<number>()
|
||
for (const key of getNormalizedProfileMatchKeys(normalized)) {
|
||
for (const index of identityIndexesByKey.get(key) || []) {
|
||
candidateIndexes.add(index)
|
||
}
|
||
}
|
||
|
||
for (const index of Array.from(candidateIndexes)) {
|
||
const score = getIdentityProfileMatchScore(identities[index], normalized)
|
||
if (score <= 0) continue
|
||
const current = bestMatchesByIdentity.get(index)
|
||
if (!current || score > current.score) {
|
||
bestMatchesByIdentity.set(index, { normalized, score })
|
||
}
|
||
}
|
||
}
|
||
|
||
return { bestMatchesByIdentity, skippedRows }
|
||
}
|
||
|
||
export function comparePerformanceSurveyEmployeeSyncPreview(input: {
|
||
identities: Array<ReportIdentity & { userId?: string }>
|
||
rows: ReportEmployeeRow[]
|
||
existingProfiles: EmployeeProfileSnapshot[]
|
||
}) {
|
||
const { identities, rows, existingProfiles } = input
|
||
const { bestMatchesByIdentity, skippedRows } = matchNormalizedRowsToIdentities(identities, rows)
|
||
const existingByKey = new Map<string, EmployeeProfileSnapshot>()
|
||
for (const profile of existingProfiles) {
|
||
for (const key of getExistingProfileMatchKeys(profile)) {
|
||
if (!existingByKey.has(key)) existingByKey.set(key, profile)
|
||
}
|
||
}
|
||
|
||
const findExisting = (proposed: PreviewProfileData) => {
|
||
for (const key of getProposedProfileMatchKeys(proposed)) {
|
||
const existing = existingByKey.get(key)
|
||
if (existing) return existing
|
||
}
|
||
return null
|
||
}
|
||
|
||
let created = 0
|
||
let updated = 0
|
||
let unchanged = 0
|
||
let fallback = 0
|
||
let skipped = skippedRows
|
||
let deduplicated = 0
|
||
const matchedIdentityIndexes = new Set<number>()
|
||
const plannedProfileKeys = new Set<string>()
|
||
const changedFields = new Map<string, number>()
|
||
|
||
const classify = (proposed: PreviewProfileData) => {
|
||
const existing = findExisting(proposed)
|
||
if (!existing) {
|
||
created += 1
|
||
return
|
||
}
|
||
|
||
const fields = hasBusinessProfileChanges(existing, {
|
||
...proposed,
|
||
sourcePayload: mergeExistingMaterialAccountLink(
|
||
proposed.sourcePayload,
|
||
existing.sourcePayload,
|
||
),
|
||
})
|
||
if (fields.length === 0) {
|
||
unchanged += 1
|
||
return
|
||
}
|
||
|
||
updated += 1
|
||
for (const field of fields) {
|
||
changedFields.set(field, (changedFields.get(field) || 0) + 1)
|
||
}
|
||
}
|
||
|
||
const materialIdentityByProfileKey = new Map<string, ReportIdentity & { userId?: string }>()
|
||
for (const [index, match] of Array.from(bestMatchesByIdentity.entries())) {
|
||
const identity = identities[index]
|
||
const profileKey = getNormalizedProfileSyncKey(match.normalized)
|
||
if (!materialIdentityByProfileKey.has(profileKey)) {
|
||
materialIdentityByProfileKey.set(profileKey, identity)
|
||
}
|
||
matchedIdentityIndexes.add(index)
|
||
}
|
||
|
||
for (const row of rows) {
|
||
const normalized = normalizeEmployeeRow(row)
|
||
if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) {
|
||
continue
|
||
}
|
||
const profileKey = getNormalizedProfileSyncKey(normalized)
|
||
if (plannedProfileKeys.has(profileKey)) {
|
||
deduplicated += 1
|
||
continue
|
||
}
|
||
|
||
plannedProfileKeys.add(profileKey)
|
||
const identity = materialIdentityByProfileKey.get(profileKey)
|
||
const proposed: PreviewProfileData = identity
|
||
? {
|
||
...normalized,
|
||
sourcePayload: {
|
||
...(normalized.sourcePayload || {}),
|
||
materialUserId: identity.userId || null,
|
||
materialUserEmail: clean(identity.email),
|
||
},
|
||
}
|
||
: normalized
|
||
classify(proposed)
|
||
}
|
||
|
||
identities.forEach((identity, index) => {
|
||
if (matchedIdentityIndexes.has(index)) return
|
||
if (
|
||
!clean(identity.dingtalkUserId)
|
||
&& !clean(identity.email)
|
||
&& !clean(identity.username)
|
||
&& !clean(identity.realName)
|
||
) {
|
||
skipped += 1
|
||
return
|
||
}
|
||
|
||
fallback += 1
|
||
classify(buildFallbackPreviewProfile(identity))
|
||
})
|
||
|
||
return {
|
||
fetched: rows.length,
|
||
matchedAccounts: matchedIdentityIndexes.size,
|
||
created,
|
||
updated,
|
||
unchanged,
|
||
fallback,
|
||
skipped,
|
||
deduplicated,
|
||
plannedProfileWrites: created + updated + unchanged,
|
||
changedFields: Array.from(changedFields.entries())
|
||
.map(([field, count]) => ({ field, count }))
|
||
.sort((left, right) => right.count - left.count || left.field.localeCompare(right.field)),
|
||
}
|
||
}
|
||
|
||
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 countEmployeeProfileBusinessReferences(tx: any, employeeProfileId: string) {
|
||
const counts = await Promise.all([
|
||
tx.performanceSurveyAssignment.count({ where: { evaluatorEmployeeId: employeeProfileId } }),
|
||
tx.performanceSurveyTarget.count({ where: { employeeProfileId } }),
|
||
tx.performanceSurveyResponse.count({ where: { respondentEmployeeId: employeeProfileId } }),
|
||
tx.performanceSurveyWorkItem.count({ where: { submitterEmployeeId: employeeProfileId } }),
|
||
tx.performanceSurveyWorkItemScore.count({ where: { evaluatorEmployeeId: employeeProfileId } }),
|
||
tx.performanceSurveyNotification.count({ where: { recipientEmployeeId: employeeProfileId } }),
|
||
tx.performanceSurveyEmployeePositionAssignment.count({ where: { employeeProfileId } }),
|
||
tx.performanceSurvey.count({ where: { creatorEmployeeId: employeeProfileId } }),
|
||
])
|
||
|
||
return counts.reduce((total: number, count: number) => total + count, 0)
|
||
}
|
||
|
||
async function resolveEmployeeProfileIdentityInTransaction(
|
||
tx: any,
|
||
data: ReturnType<typeof normalizeEmployeeRow>,
|
||
) {
|
||
const strongIdentityConditions = [
|
||
data.dingtalkUserId ? { dingtalkUserId: data.dingtalkUserId } : null,
|
||
data.jobNumber ? { jobNumber: data.jobNumber } : null,
|
||
].filter(Boolean) as any[]
|
||
|
||
let matches = strongIdentityConditions.length > 0
|
||
? await tx.performanceSurveyEmployeeProfile.findMany({
|
||
where: { OR: strongIdentityConditions },
|
||
orderBy: { createdAt: 'asc' },
|
||
})
|
||
: []
|
||
|
||
if (matches.length === 0 && data.dingEmail) {
|
||
const emailMatch = await tx.performanceSurveyEmployeeProfile.findFirst({
|
||
where: { dingEmail: data.dingEmail },
|
||
orderBy: { createdAt: 'asc' },
|
||
})
|
||
matches = emailMatch ? [emailMatch] : []
|
||
}
|
||
|
||
if (matches.length <= 1) return matches[0] || null
|
||
|
||
const matchesWithReferences = await Promise.all(
|
||
matches.map(async (profile: any) => ({
|
||
profile,
|
||
businessReferenceCount: await countEmployeeProfileBusinessReferences(tx, profile.id),
|
||
})),
|
||
)
|
||
const referencedMatches = matchesWithReferences.filter((item) => item.businessReferenceCount > 0)
|
||
|
||
if (referencedMatches.length > 1) {
|
||
throw new Error(
|
||
`人员身份冲突:钉钉账号与工号分别命中了多条已承载考核数据的档案(${referencedMatches
|
||
.map((item) => item.profile.id)
|
||
.join(', ')}),为避免错误合并已中止同步`,
|
||
)
|
||
}
|
||
|
||
const canonical = referencedMatches[0]?.profile
|
||
|| matches.find((profile: any) => (
|
||
data.dingtalkUserId && profile.dingtalkUserId === data.dingtalkUserId
|
||
))
|
||
|| matches.find((profile: any) => (
|
||
data.jobNumber && profile.jobNumber === data.jobNumber
|
||
))
|
||
|| matches[0]
|
||
|
||
for (const item of matchesWithReferences) {
|
||
if (item.profile.id === canonical.id) continue
|
||
if (item.businessReferenceCount > 0) {
|
||
throw new Error(`人员身份冲突:档案 ${item.profile.id} 已承载考核数据,无法自动合并`)
|
||
}
|
||
await tx.performanceSurveyEmployeeProfile.delete({
|
||
where: { id: item.profile.id },
|
||
})
|
||
}
|
||
|
||
return canonical
|
||
}
|
||
|
||
export async function upsertPerformanceSurveyEmployeeProfileForSync(
|
||
data: ReturnType<typeof normalizeEmployeeRow>,
|
||
) {
|
||
const { departmentMemberships, ...profileData } = data
|
||
|
||
return prisma.$transaction(async (tx) => {
|
||
const existing = await resolveEmployeeProfileIdentityInTransaction(tx, data)
|
||
const profileId = existing?.id || randomUUID()
|
||
const profileWriteData = {
|
||
...profileData,
|
||
sourcePayload: mergeExistingMaterialAccountLink(
|
||
profileData.sourcePayload,
|
||
existing?.sourcePayload,
|
||
) as any,
|
||
}
|
||
const profile = existing
|
||
? await tx.performanceSurveyEmployeeProfile.update({
|
||
where: { id: existing.id },
|
||
data: profileWriteData,
|
||
})
|
||
: await tx.performanceSurveyEmployeeProfile.create({
|
||
data: {
|
||
id: profileId,
|
||
...profileWriteData,
|
||
},
|
||
})
|
||
|
||
await tx.performanceSurveyEmployeeDepartmentMembership.deleteMany({
|
||
where: { employeeProfileId: profile.id },
|
||
})
|
||
|
||
if (departmentMemberships.length > 0) {
|
||
const syncedAt = new Date()
|
||
await tx.performanceSurveyEmployeeDepartmentMembership.createMany({
|
||
data: departmentMemberships.map((membership) => ({
|
||
id: randomUUID(),
|
||
employeeProfileId: profile.id,
|
||
sourceDeptId: membership.sourceDeptId,
|
||
departmentName: membership.departmentName,
|
||
departmentPath: membership.departmentPath,
|
||
isPrimary: membership.isPrimary,
|
||
syncedAt,
|
||
})),
|
||
})
|
||
}
|
||
|
||
return profile
|
||
})
|
||
}
|
||
|
||
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 employees = await prisma.performanceSurveyEmployeeProfile.findMany({
|
||
where: {
|
||
AND: [
|
||
getActivePerformanceSurveyEmployeeProfileWhere(),
|
||
{ departmentName: { not: null } },
|
||
],
|
||
},
|
||
select: {
|
||
departmentName: true,
|
||
departmentPath: true,
|
||
departmentMemberships: {
|
||
select: {
|
||
departmentName: true,
|
||
departmentPath: true,
|
||
},
|
||
},
|
||
},
|
||
})
|
||
|
||
const grouped = new Map<string, { name: string; parentName: string | null; count: number }>()
|
||
for (const employee of employees) {
|
||
const departments = employee.departmentMemberships.length > 0
|
||
? employee.departmentMemberships
|
||
: [{
|
||
departmentName: employee.departmentName,
|
||
departmentPath: employee.departmentPath,
|
||
}]
|
||
const seenNames = new Set<string>()
|
||
for (const department of departments) {
|
||
if (!department.departmentName || seenNames.has(department.departmentName)) continue
|
||
seenNames.add(department.departmentName)
|
||
const existing = grouped.get(department.departmentName)
|
||
grouped.set(department.departmentName, {
|
||
name: department.departmentName,
|
||
parentName: existing?.parentName || getParentDepartmentName(
|
||
department.departmentName,
|
||
department.departmentPath,
|
||
),
|
||
count: (existing?.count || 0) + 1,
|
||
})
|
||
}
|
||
}
|
||
|
||
let count = 0
|
||
for (const item of Array.from(grouped.values())) {
|
||
await prisma.performanceSurveyDepartmentProfile.upsert({
|
||
where: { name: item.name },
|
||
update: {
|
||
parentName: item.parentName,
|
||
employeeCount: item.count,
|
||
syncedAt: new Date(),
|
||
},
|
||
create: {
|
||
id: randomUUID(),
|
||
name: item.name,
|
||
parentName: item.parentName,
|
||
employeeCount: item.count,
|
||
syncedAt: new Date(),
|
||
},
|
||
})
|
||
count += 1
|
||
}
|
||
|
||
return count
|
||
}
|
||
|
||
function shouldRefreshProfile(profile: { syncedAt?: Date | null; departmentName?: string | null; positionLevel?: string | null }) {
|
||
if (!profile.syncedAt) return true
|
||
const hours = Math.max(1, Number(process.env.PERFORMANCE_SURVEY_PROFILE_SYNC_HOURS || 24))
|
||
return Date.now() - profile.syncedAt.getTime() > hours * 60 * 60 * 1000
|
||
}
|
||
|
||
async function getReportPool() {
|
||
const sql = mssql as typeof import('mssql')
|
||
return {
|
||
sql,
|
||
pool: await sql.connect(getReportDbConfig() as any),
|
||
}
|
||
}
|
||
|
||
async function fetchNormalizedEmployeeByIdentityWithContext(
|
||
pool: any,
|
||
sql: typeof import('mssql'),
|
||
schema: Awaited<ReturnType<typeof getReportSchema>>,
|
||
identity: ReportIdentity,
|
||
) {
|
||
const where = buildIdentityWhere(
|
||
schema.employeeColumns,
|
||
schema.registerColumns,
|
||
schema.flowerColumns,
|
||
identity,
|
||
)
|
||
if (where.whereSql === '1 = 0') return null
|
||
|
||
const request = pool.request().input('limit', sql.Int, 1)
|
||
if (clean(identity.dingtalkUserId)) request.input('dingtalkUserId', sql.NVarChar(200), clean(identity.dingtalkUserId))
|
||
if (clean(identity.email)) request.input('email', sql.NVarChar(320), clean(identity.email))
|
||
if (where.nameParam) request.input('name', sql.NVarChar(200), where.nameParam)
|
||
|
||
const result = await request.query(
|
||
buildSelectSql(
|
||
schema.employeeColumns,
|
||
schema.registerColumns,
|
||
schema.flowerColumns,
|
||
where.whereSql,
|
||
'@limit',
|
||
),
|
||
)
|
||
const rows = await applyResolvedDepartmentPaths(pool, result.recordset || [])
|
||
const row = rows[0] as ReportEmployeeRow | undefined
|
||
if (!row) return null
|
||
|
||
const normalized = normalizeEmployeeRow(row)
|
||
;(normalized as any).sourcePayload = {
|
||
...(normalized.sourcePayload || {}),
|
||
materialUserId: identity.userId || null,
|
||
materialUserEmail: clean(identity.email),
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
async function syncPerformanceSurveyEmployeeByIdentityWithContext(
|
||
pool: any,
|
||
sql: typeof import('mssql'),
|
||
schema: Awaited<ReturnType<typeof getReportSchema>>,
|
||
identity: ReportIdentity,
|
||
) {
|
||
const normalized = await fetchNormalizedEmployeeByIdentityWithContext(pool, sql, schema, identity)
|
||
if (!normalized) return null
|
||
if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) return null
|
||
return upsertPerformanceSurveyEmployeeProfileForSync(normalized)
|
||
}
|
||
|
||
async function upsertFallbackEmployeeProfileForUser(identity: ReportIdentity & { userId?: string }) {
|
||
const existing = await findExistingEmployeeProfile({
|
||
dingtalkUserId: clean(identity.dingtalkUserId),
|
||
dingEmail: clean(identity.email),
|
||
})
|
||
const data = {
|
||
dingtalkUserId: clean(identity.dingtalkUserId),
|
||
dingEmail: clean(identity.email),
|
||
displayName: clean(identity.flowerName) || clean(identity.displayName) || clean(identity.realName) || clean(identity.username) || '未知员工',
|
||
realName: clean(identity.realName) || clean(identity.username),
|
||
flowerName: clean(identity.flowerName) || null,
|
||
departmentName: clean(identity.department),
|
||
departmentPath: clean(identity.department),
|
||
sourcePayload: {
|
||
source: 'material_user_fallback',
|
||
userId: identity.userId || null,
|
||
materialUserId: identity.userId || null,
|
||
materialUserEmail: clean(identity.email),
|
||
},
|
||
syncedAt: new Date(),
|
||
}
|
||
|
||
if (existing) {
|
||
return prisma.performanceSurveyEmployeeProfile.update({
|
||
where: { id: existing.id },
|
||
data,
|
||
})
|
||
}
|
||
|
||
return prisma.performanceSurveyEmployeeProfile.create({
|
||
data: {
|
||
id: randomUUID(),
|
||
...data,
|
||
},
|
||
})
|
||
}
|
||
|
||
export async function syncPerformanceSurveyEmployeeByIdentity(identity: ReportIdentity) {
|
||
if (!isPerformanceSurveyReportDbConfigured()) return null
|
||
|
||
const { sql, pool } = await getReportPool()
|
||
try {
|
||
const schema = await getReportSchema(pool)
|
||
const profile = await syncPerformanceSurveyEmployeeByIdentityWithContext(pool, sql, schema, identity)
|
||
if (profile) await upsertDepartments()
|
||
return profile
|
||
} finally {
|
||
await pool.close()
|
||
}
|
||
}
|
||
|
||
export async function previewPerformanceSurveyEmployeeByIdentity(identity: ReportIdentity) {
|
||
if (!isPerformanceSurveyReportDbConfigured()) return null
|
||
|
||
const { sql, pool } = await getReportPool()
|
||
try {
|
||
const schema = await getReportSchema(pool)
|
||
return fetchNormalizedEmployeeByIdentityWithContext(pool, sql, schema, identity)
|
||
} finally {
|
||
await pool.close()
|
||
}
|
||
}
|
||
|
||
function getProfileRefreshKey(identity: ReportIdentity & { userId?: string }) {
|
||
return clean(identity.dingtalkUserId)
|
||
|| clean(identity.email)?.toLowerCase()
|
||
|| clean(identity.userId)
|
||
|| clean(identity.username)?.toLowerCase()
|
||
|| clean(identity.realName)
|
||
|| clean(identity.displayName)
|
||
|| 'anonymous'
|
||
}
|
||
|
||
function refreshPerformanceSurveyEmployeeProfileInBackground(identity: ReportIdentity & { userId?: string }) {
|
||
if (!isPerformanceSurveyReportDbConfigured()) return
|
||
|
||
const key = getProfileRefreshKey(identity)
|
||
pruneProfileRefreshTracking()
|
||
if (profileRefreshInFlight.has(key)) return
|
||
if (profileRefreshInFlight.size >= PROFILE_BACKGROUND_REFRESH_MAX_IN_FLIGHT) return
|
||
|
||
const lastStartedAt = profileRefreshStartedAt.get(key) || 0
|
||
if (Date.now() - lastStartedAt < PROFILE_BACKGROUND_REFRESH_MIN_MS) return
|
||
|
||
profileRefreshStartedAt.set(key, Date.now())
|
||
const refreshPromise = syncPerformanceSurveyEmployeeByIdentity(identity)
|
||
.catch((error) => {
|
||
console.warn('[performance-survey] 后台人员档案同步失败:', error)
|
||
})
|
||
.finally(() => {
|
||
profileRefreshInFlight.delete(key)
|
||
pruneProfileRefreshTracking()
|
||
})
|
||
|
||
profileRefreshInFlight.set(key, refreshPromise)
|
||
pruneProfileRefreshTracking()
|
||
}
|
||
|
||
export async function ensurePerformanceSurveyEmployeeProfileForUser(identity: ReportIdentity & { userId?: string }) {
|
||
const existing = await findExistingEmployeeProfile({
|
||
dingtalkUserId: clean(identity.dingtalkUserId),
|
||
dingEmail: clean(identity.email),
|
||
})
|
||
|
||
if (existing) {
|
||
if (shouldRefreshProfile(existing)) {
|
||
refreshPerformanceSurveyEmployeeProfileInBackground(identity)
|
||
}
|
||
return existing
|
||
}
|
||
|
||
const fallback = await upsertFallbackEmployeeProfileForUser(identity)
|
||
refreshPerformanceSurveyEmployeeProfileInBackground(identity)
|
||
return fallback
|
||
}
|
||
|
||
export function ensurePerformanceSurveyDailyEmployeeSyncStarted() {
|
||
if (dailyWorkerStarted) return
|
||
dailyWorkerStarted = true
|
||
|
||
const intervalHours = Math.max(1, Number(process.env.PERFORMANCE_SURVEY_DAILY_SYNC_INTERVAL_HOURS || 24))
|
||
const initialDelayMs = Math.max(10, Number(process.env.PERFORMANCE_SURVEY_DAILY_SYNC_INITIAL_DELAY_SECONDS || 300)) * 1000
|
||
const intervalMs = intervalHours * 60 * 60 * 1000
|
||
const limit = Math.max(1, Math.min(Number(process.env.PERFORMANCE_SURVEY_DAILY_SYNC_LIMIT || 3000), 10_000))
|
||
|
||
const run = async () => {
|
||
if (!isPerformanceSurveyReportDbConfigured()) return
|
||
try {
|
||
await syncPerformanceSurveyEmployees({ limit })
|
||
} catch (error) {
|
||
console.warn('[performance-survey] 每日人员档案同步失败:', error)
|
||
}
|
||
}
|
||
|
||
const bootstrap = setTimeout(() => void run(), initialDelayMs)
|
||
bootstrap.unref?.()
|
||
|
||
const timer = setInterval(() => void run(), intervalMs)
|
||
timer.unref?.()
|
||
}
|
||
|
||
export async function previewPerformanceSurveyEmployeeSync(options?: { limit?: number }) {
|
||
const startedAt = new Date()
|
||
const limit = normalizeEmployeeSyncLimit(options?.limit)
|
||
const [identities, totalAccounts, existingProfiles] = await Promise.all([
|
||
listMaterialAccountSyncIdentities(limit),
|
||
countMaterialAccountSyncIdentities(),
|
||
prisma.performanceSurveyEmployeeProfile.findMany({
|
||
select: {
|
||
id: true,
|
||
dingtalkUserId: true,
|
||
dingEmail: true,
|
||
jobNumber: true,
|
||
flowerName: true,
|
||
realName: true,
|
||
displayName: true,
|
||
departmentName: true,
|
||
departmentPath: true,
|
||
positionTitle: true,
|
||
positionLevel: true,
|
||
levelType: true,
|
||
isLeadership: true,
|
||
isDingtalkAdmin: true,
|
||
isDingtalkBoss: true,
|
||
isDingtalkSenior: true,
|
||
managerDingtalkUserId: true,
|
||
managerJobNumber: true,
|
||
managerName: true,
|
||
employmentStatus: true,
|
||
workPlace: true,
|
||
sourcePayload: true,
|
||
syncedAt: true,
|
||
departmentMemberships: {
|
||
select: {
|
||
sourceDeptId: true,
|
||
departmentName: true,
|
||
departmentPath: true,
|
||
isPrimary: true,
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
])
|
||
const configured = isPerformanceSurveyReportDbConfigured()
|
||
let rows: ReportEmployeeRow[] = []
|
||
|
||
if (configured) {
|
||
const { sql, pool } = await getReportPool()
|
||
try {
|
||
const schema = await getReportSchema(pool)
|
||
rows = await fetchFullSyncRows(pool, sql, schema, limit)
|
||
} finally {
|
||
await pool.close()
|
||
}
|
||
}
|
||
|
||
const comparison = comparePerformanceSurveyEmployeeSyncPreview({
|
||
identities,
|
||
rows,
|
||
existingProfiles: existingProfiles as EmployeeProfileSnapshot[],
|
||
})
|
||
const completedAt = new Date()
|
||
const latestLocalSyncedAt = existingProfiles.reduce<Date | null>((latest, profile) => {
|
||
if (!profile.syncedAt) return latest
|
||
return !latest || profile.syncedAt > latest ? profile.syncedAt : latest
|
||
}, null)
|
||
const warnings = [
|
||
'这是只读预检:只读取素材库账号、本地人员档案和完整在职花名册,不会写入或停用任何人员。',
|
||
'系统尚未记录“整批同步成功时间”;last.latestLocalProfileSyncedAt 仅表示最近一条本地人员档案的同步时间。',
|
||
'现有正式同步不会把“本次上游未返回”直接视为离职或停用,因此停用人数无法可靠推断。',
|
||
]
|
||
|
||
if (totalAccounts > identities.length) {
|
||
warnings.push(`本次只读取了 ${identities.length}/${totalAccounts} 个素材库账号用于建立账号关联;花名册人员仍按上限独立读取。`)
|
||
}
|
||
if (configured && rows.length >= limit) {
|
||
warnings.push(`本次花名册读取达到上限 ${limit} 人,可能不是完整人员范围,请提高同步上限后重试。`)
|
||
}
|
||
if (!configured) {
|
||
warnings.push('上游花名册数据库未配置;正式同步只会使用素材库账号建立或更新兜底档案。')
|
||
} else if (comparison.fallback > 0) {
|
||
warnings.push(`${comparison.fallback} 个素材库账号未匹配到上游花名册,正式同步时会使用素材库账号信息作为兜底。`)
|
||
}
|
||
if (comparison.skipped > 0) {
|
||
warnings.push(`${comparison.skipped} 条数据缺少可用身份标识,正式同步时会跳过。`)
|
||
}
|
||
|
||
return {
|
||
mode: 'preview',
|
||
dryRun: true,
|
||
source: {
|
||
type: configured ? 'report_database' : 'material_user_fallback',
|
||
name: configured ? 'JSReportReplica.rpt' : '素材库用户表',
|
||
tables: configured
|
||
? [
|
||
`${EMPLOYEE_TABLE.schema}.${EMPLOYEE_TABLE.table}`,
|
||
`${REGISTER_TABLE.schema}.${REGISTER_TABLE.table}`,
|
||
`${FLOWER_TABLE.schema}.${FLOWER_TABLE.table}`,
|
||
]
|
||
: ['User'],
|
||
access: 'read_only',
|
||
trigger: 'admin_manual_preview',
|
||
upstreamQueried: configured,
|
||
},
|
||
last: {
|
||
status: latestLocalSyncedAt ? 'approximate' : 'unavailable',
|
||
latestLocalProfileSyncedAt: latestLocalSyncedAt?.toISOString() || null,
|
||
localProfileCount: existingProfiles.length,
|
||
note: latestLocalSyncedAt
|
||
? '该时间来自最近一条本地人员档案,不代表最近一次整批同步成功时间。'
|
||
: '本地尚无可用的人员档案同步时间。',
|
||
},
|
||
now: {
|
||
requestedLimit: limit,
|
||
totalAccounts,
|
||
scannedAccounts: identities.length,
|
||
scannedProfiles: rows.length,
|
||
truncated: configured ? rows.length >= limit : totalAccounts > identities.length,
|
||
previewStartedAt: startedAt.toISOString(),
|
||
previewCompletedAt: completedAt.toISOString(),
|
||
durationMs: completedAt.getTime() - startedAt.getTime(),
|
||
databaseWritten: false,
|
||
},
|
||
counts: {
|
||
fetched: comparison.fetched,
|
||
matchedAccounts: comparison.matchedAccounts,
|
||
created: comparison.created,
|
||
updated: comparison.updated,
|
||
unchanged: comparison.unchanged,
|
||
fallback: comparison.fallback,
|
||
skipped: comparison.skipped,
|
||
deduplicated: comparison.deduplicated,
|
||
plannedProfileWrites: comparison.plannedProfileWrites,
|
||
deactivated: null,
|
||
},
|
||
deactivation: {
|
||
status: 'unsupported',
|
||
count: null,
|
||
reason: '正式同步不会把“本次上游未返回”直接视为离职或停用,无法仅根据一次读取可靠计算停用人数。',
|
||
},
|
||
changedFields: comparison.changedFields,
|
||
warnings,
|
||
}
|
||
}
|
||
|
||
export async function syncPerformanceSurveyEmployees(options?: { limit?: number }) {
|
||
const limit = normalizeEmployeeSyncLimit(options?.limit)
|
||
const identities = await listMaterialAccountSyncIdentities(limit)
|
||
|
||
if (!isPerformanceSurveyReportDbConfigured()) {
|
||
for (const identity of identities) {
|
||
await upsertFallbackEmployeeProfileForUser(identity)
|
||
}
|
||
const departmentCount = await upsertDepartments()
|
||
invalidatePerformanceSurveyAccountProfileWhereCache()
|
||
return {
|
||
scannedAccounts: identities.length,
|
||
scannedProfiles: 0,
|
||
upserted: 0,
|
||
fallback: identities.length,
|
||
skipped: 0,
|
||
failed: 0,
|
||
departmentCount,
|
||
syncedAt: new Date().toISOString(),
|
||
}
|
||
}
|
||
|
||
const { sql, pool } = await getReportPool()
|
||
|
||
try {
|
||
const schema = await getReportSchema(pool)
|
||
const rows = await fetchFullSyncRows(pool, sql, schema, limit)
|
||
const { bestMatchesByIdentity } = matchNormalizedRowsToIdentities(identities, rows)
|
||
|
||
let upserted = 0
|
||
let fallback = 0
|
||
let skipped = 0
|
||
let failed = 0
|
||
const matchedIdentityIndexes = new Set<number>()
|
||
const materialIdentityByProfileKey = new Map<string, ReportIdentity & { userId?: string }>()
|
||
for (const [index, match] of Array.from(bestMatchesByIdentity.entries())) {
|
||
const profileKey = getNormalizedProfileSyncKey(match.normalized)
|
||
if (!materialIdentityByProfileKey.has(profileKey)) {
|
||
materialIdentityByProfileKey.set(profileKey, identities[index])
|
||
}
|
||
matchedIdentityIndexes.add(index)
|
||
}
|
||
|
||
const syncedProfileKeys = new Set<string>()
|
||
for (const row of rows) {
|
||
const normalized = normalizeEmployeeRow(row)
|
||
if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) {
|
||
skipped += 1
|
||
continue
|
||
}
|
||
const profileKey = getNormalizedProfileSyncKey(normalized)
|
||
if (syncedProfileKeys.has(profileKey)) {
|
||
skipped += 1
|
||
continue
|
||
}
|
||
syncedProfileKeys.add(profileKey)
|
||
|
||
const identity = materialIdentityByProfileKey.get(profileKey)
|
||
if (identity) {
|
||
;(normalized as any).sourcePayload = {
|
||
...(normalized.sourcePayload || {}),
|
||
materialUserId: identity.userId || null,
|
||
materialUserEmail: clean(identity.email),
|
||
}
|
||
}
|
||
|
||
try {
|
||
await upsertPerformanceSurveyEmployeeProfileForSync(normalized)
|
||
upserted += 1
|
||
} catch (error) {
|
||
failed += 1
|
||
console.warn('[performance-survey] 单个人员档案同步失败:', {
|
||
dingtalkUserId: normalized.dingtalkUserId,
|
||
dingEmail: normalized.dingEmail,
|
||
error,
|
||
})
|
||
}
|
||
}
|
||
|
||
for (let index = 0; index < identities.length; index += 1) {
|
||
const identity = identities[index]
|
||
if (matchedIdentityIndexes.has(index)) continue
|
||
if (!clean(identity.dingtalkUserId) && !clean(identity.email) && !clean(identity.username) && !clean(identity.realName)) {
|
||
skipped += 1
|
||
continue
|
||
}
|
||
try {
|
||
await upsertFallbackEmployeeProfileForUser(identity)
|
||
fallback += 1
|
||
} catch (error) {
|
||
failed += 1
|
||
console.warn('[performance-survey] 素材库账号兜底档案同步失败:', {
|
||
dingtalkUserId: identity.dingtalkUserId,
|
||
email: identity.email,
|
||
error,
|
||
})
|
||
}
|
||
}
|
||
|
||
const departmentCount = await upsertDepartments()
|
||
invalidatePerformanceSurveyAccountProfileWhereCache()
|
||
|
||
return {
|
||
scannedAccounts: identities.length,
|
||
scannedProfiles: rows.length,
|
||
upserted,
|
||
fallback,
|
||
skipped,
|
||
failed,
|
||
departmentCount,
|
||
syncedAt: new Date().toISOString(),
|
||
}
|
||
} finally {
|
||
await pool.close()
|
||
}
|
||
}
|