- hrSyncService: 新增 SQLSERVER_SCHEMA(默认 rpt, 带格式校验防注入)
- 3 处 dbo.hr_employee_register/hr_employee 改为动态 ${schema}.表名
- .env.example / docker-compose.yml 注入 SQLSERVER_SCHEMA
- 配合上游通知: 老业务库迁到 JSReportReplica.rpt, 只读使用
1978 lines
54 KiB
TypeScript
1978 lines
54 KiB
TypeScript
import prisma from '../utils/prisma'
|
||
import sql from 'mssql'
|
||
import {
|
||
HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM,
|
||
HR_SOURCE_SYSTEM,
|
||
OTHER_DEPARTMENT_NAME,
|
||
buildBusinessDepartmentWhere,
|
||
buildSyncedBusinessDepartmentWhere
|
||
} from '../utils/organization'
|
||
import {
|
||
buildHrSyncPreviewLine,
|
||
resolveHrSyncPersonDisplayName,
|
||
resolveSourcePersonStatus,
|
||
shouldUseSourceDepartmentBinding,
|
||
type OpenBorrowAssetPreview
|
||
} from './hrSyncHelpers'
|
||
|
||
type SourceDepartment = {
|
||
sourceGroupId: number | null
|
||
sourceParentGroupId: number | null
|
||
name: string
|
||
}
|
||
|
||
type HrSyncSourceMode = 'register_primary'
|
||
|
||
type SourcePresence = 'employee_and_register' | 'register_only'
|
||
|
||
type DepartmentSyncBucket = 'formal' | 'fallback' | 'other'
|
||
|
||
type SourcePersonSpecialExclusionReason =
|
||
| 'business_support_blank_job_number'
|
||
| 'longchen_brand_center_register_only'
|
||
|
||
type SourcePerson = {
|
||
sourceUserId: string
|
||
nickname: string
|
||
realName: string
|
||
idNumber: string | null
|
||
sourceDepartmentName: string | null
|
||
sourceMainDepartmentName: string | null
|
||
position: string | null
|
||
phone: string | null
|
||
email: string | null
|
||
sourceMainDepartmentId: number | null
|
||
sourceDeptIds: string | null
|
||
sourceJobNumber: string | null
|
||
managerSourceUserId: string | null
|
||
sourceEmployeeStatus: string | null
|
||
status: 'active'
|
||
resolvedDepartmentName: string
|
||
departmentSyncBucket: DepartmentSyncBucket
|
||
sourcePresence: SourcePresence
|
||
}
|
||
|
||
type BusinessDepartmentCandidate = {
|
||
name: string
|
||
sourcePersonCount: number
|
||
buckets: DepartmentSyncBucket[]
|
||
}
|
||
|
||
type HrSyncResult = {
|
||
departments: {
|
||
total: number
|
||
created: number
|
||
updated: number
|
||
deactivated: number
|
||
}
|
||
persons: {
|
||
total: number
|
||
created: number
|
||
updated: number
|
||
deactivated: number
|
||
}
|
||
equipment_department_migration?: {
|
||
migrated: number
|
||
unresolved: number
|
||
samples: Array<{
|
||
equipment_id: number
|
||
equipment_code: string
|
||
equipment_name: string
|
||
legacy_department_name: string | null
|
||
}>
|
||
}
|
||
cleanup?: {
|
||
deleted_inactive_persons: number
|
||
remapped_legacy_person_departments: number
|
||
deleted_legacy_departments: number
|
||
deleted_inactive_business_departments: number
|
||
}
|
||
sync_job_id?: number
|
||
changes_count?: number
|
||
}
|
||
|
||
type SyncTriggerMode = 'manual' | 'scheduler'
|
||
|
||
type DepartmentSnapshot = {
|
||
id: number
|
||
name: string
|
||
status: string
|
||
}
|
||
|
||
type PersonSnapshot = {
|
||
id: number
|
||
sourceUserId: string
|
||
nickname: string
|
||
realName: string
|
||
status: string
|
||
position: string | null
|
||
department: string | null
|
||
mainDepartmentId: number | null
|
||
mainDepartmentName: string | null
|
||
departmentIds: number[]
|
||
departmentNames: string[]
|
||
departmentSyncBucket: string | null
|
||
}
|
||
|
||
type HrSyncChangeDraft = {
|
||
entityType: 'person' | 'department' | 'equipment'
|
||
entityKey: string
|
||
entityName: string
|
||
changeType: string
|
||
title: string
|
||
detail: string
|
||
countWeight?: number
|
||
beforeSnapshot?: Record<string, any> | null
|
||
afterSnapshot?: Record<string, any> | null
|
||
}
|
||
|
||
type HrSyncSummary = {
|
||
title: string
|
||
content: string
|
||
counts: Record<string, number>
|
||
previewChanges: Array<{
|
||
title: string
|
||
detail: string
|
||
change_type: string
|
||
}>
|
||
}
|
||
|
||
type LocalDepartmentMap = Map<string, { id: number; name: string }>
|
||
|
||
type SourcePersonCollection = {
|
||
persons: SourcePerson[]
|
||
sourceMode: HrSyncSourceMode
|
||
sourcePresenceSummary: Record<SourcePresence, number>
|
||
departmentBucketSummary: Record<DepartmentSyncBucket, number>
|
||
specialExclusionSummary: Record<SourcePersonSpecialExclusionReason, number>
|
||
}
|
||
|
||
const REQUIRED_SQLSERVER_FIELDS = [
|
||
'SQLSERVER_HOST',
|
||
'SQLSERVER_DATABASE',
|
||
'SQLSERVER_USER',
|
||
'SQLSERVER_PASSWORD'
|
||
] as const
|
||
const DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE = 'Asia/Shanghai'
|
||
// 默认上游 schema:老业务库为 dbo,迁移后的报表库为 rpt
|
||
const DEFAULT_SOURCE_SCHEMA = 'rpt'
|
||
|
||
const departmentCollator = new Intl.Collator('zh-CN', {
|
||
numeric: true,
|
||
sensitivity: 'base'
|
||
})
|
||
|
||
let schedulerStarted = false
|
||
let schedulerRunning = false
|
||
const lastSchedulerRunKeys = new Set<string>()
|
||
|
||
const CHANGE_TYPE_LABELS: Record<string, string> = {
|
||
person_created: '新人入职',
|
||
person_deactivated: '人员离职/停用',
|
||
person_reactivated: '人员重新启用',
|
||
person_main_department_changed: '主部门变动',
|
||
person_departments_changed: '关联部门变动',
|
||
person_position_changed: '岗位变动',
|
||
department_created: '新增部门',
|
||
department_deactivated: '部门停用',
|
||
department_reactivated: '部门重新启用',
|
||
equipment_department_unresolved: '资产部门待人工处理'
|
||
}
|
||
|
||
const getConfiguredSet = (value: string, defaultValue: string) =>
|
||
new Set(
|
||
((value && value.trim() ? value : defaultValue) || '')
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
)
|
||
|
||
const getExcludedSourceDepartmentNames = () =>
|
||
getConfiguredSet(
|
||
process.env.HR_SYNC_EXCLUDED_DEPARTMENT_NAMES || process.env.HR_SYNC_EXCLUDED_DEPARTMENTS || '',
|
||
'供应商,快递公司,外部交流'
|
||
)
|
||
|
||
const getHrSyncSourceMode = (): HrSyncSourceMode => 'register_primary'
|
||
|
||
const isValidTimeZone = (timeZone: string) => {
|
||
try {
|
||
new Intl.DateTimeFormat('zh-CN', { timeZone }).format(new Date())
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
const getConfiguredScheduleTimezone = () => {
|
||
const configuredTimezone = (process.env.HR_SYNC_TIMEZONE || '').trim()
|
||
if (!configuredTimezone) {
|
||
return DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE
|
||
}
|
||
|
||
if (isValidTimeZone(configuredTimezone)) {
|
||
return configuredTimezone
|
||
}
|
||
|
||
console.warn(
|
||
`[hr-sync] invalid HR_SYNC_TIMEZONE=${configuredTimezone}, fallback=${DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE}`
|
||
)
|
||
return DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE
|
||
}
|
||
|
||
const getCurrentClockInTimeZone = (date: Date, timeZone: string) => {
|
||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||
timeZone,
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hourCycle: 'h23'
|
||
})
|
||
const partMap = new Map<string, string>()
|
||
|
||
for (const part of formatter.formatToParts(date)) {
|
||
if (part.type !== 'literal') {
|
||
partMap.set(part.type, part.value)
|
||
}
|
||
}
|
||
|
||
const year = partMap.get('year') || '0000'
|
||
const month = partMap.get('month') || '00'
|
||
const day = partMap.get('day') || '00'
|
||
const hour = partMap.get('hour') || '00'
|
||
const minute = partMap.get('minute') || '00'
|
||
|
||
return {
|
||
dateKey: `${year}-${month}-${day}`,
|
||
timeKey: `${hour}:${minute}`
|
||
}
|
||
}
|
||
|
||
export const getHrSyncConfigStatus = () => {
|
||
const missingFields = REQUIRED_SQLSERVER_FIELDS.filter((field) => {
|
||
const value = process.env[field]
|
||
return !value || !String(value).trim()
|
||
})
|
||
|
||
return {
|
||
enabled: process.env.HR_SYNC_ENABLED === 'true',
|
||
complete: missingFields.length === 0,
|
||
missingFields,
|
||
scheduleTimes: getConfiguredScheduleTimes(),
|
||
scheduleTimezone: getConfiguredScheduleTimezone()
|
||
}
|
||
}
|
||
|
||
const getSqlServerConfig = (): any => {
|
||
const server = process.env.SQLSERVER_HOST
|
||
const port = Number(process.env.SQLSERVER_PORT || '1433')
|
||
const database = process.env.SQLSERVER_DATABASE
|
||
const user = process.env.SQLSERVER_USER
|
||
const password = process.env.SQLSERVER_PASSWORD
|
||
const encrypt = process.env.SQLSERVER_ENCRYPT === 'true'
|
||
const trustServerCertificate = process.env.SQLSERVER_TRUST_CERT !== 'false'
|
||
const syncStatus = getHrSyncConfigStatus()
|
||
|
||
if (!syncStatus.complete) {
|
||
throw new Error(`SQL Server 同步配置不完整,请设置:${syncStatus.missingFields.join('、')}`)
|
||
}
|
||
|
||
return {
|
||
server,
|
||
port,
|
||
database,
|
||
user,
|
||
password,
|
||
options: {
|
||
encrypt,
|
||
trustServerCertificate
|
||
},
|
||
pool: {
|
||
max: 5,
|
||
min: 0,
|
||
idleTimeoutMillis: 30000
|
||
}
|
||
}
|
||
}
|
||
|
||
// 解析上游报表库 schema,用于把 dbo.表名 迁移为可配置 schema(默认 rpt)。
|
||
// 只允许标识符字符(字母/数字/下划线),避免环境变量内容被直接拼进 SQL 造成注入。
|
||
const getSourceSchema = (): string => {
|
||
const raw = (process.env.SQLSERVER_SCHEMA || DEFAULT_SOURCE_SCHEMA).trim()
|
||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw)) {
|
||
throw new Error(`SQLSERVER_SCHEMA 非法:${raw}(只允许字母、数字、下划线,且不能以数字开头)`)
|
||
}
|
||
return raw
|
||
}
|
||
|
||
const safeTrim = (value: unknown) => {
|
||
if (value === null || value === undefined) return null
|
||
const text = String(value).trim()
|
||
return text ? text : null
|
||
}
|
||
|
||
const toJsonText = (value: unknown) => JSON.stringify(value ?? null)
|
||
|
||
const normalizeNullableText = (value: string | null | undefined) => safeTrim(value)
|
||
|
||
const areNumberArraysEqual = (left: number[], right: number[]) =>
|
||
left.length === right.length && left.every((value, index) => value === right[index])
|
||
|
||
const formatNameList = (names: string[]) => (names.length > 0 ? names.join('、') : '未设置')
|
||
|
||
const formatDepartmentName = (value: string | null | undefined) => value || '未设置'
|
||
|
||
const inferTriggerMode = (operator: string): SyncTriggerMode =>
|
||
operator.includes('scheduler') ? 'scheduler' : 'manual'
|
||
|
||
const getConfiguredScheduleTimes = () => {
|
||
const scheduleTimes = Array.from(
|
||
new Set(
|
||
(process.env.HR_SYNC_SCHEDULES || process.env.HR_SYNC_SCHEDULE || '03:00')
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter((item) => /^\d{2}:\d{2}$/.test(item))
|
||
)
|
||
)
|
||
|
||
return scheduleTimes.length > 0 ? scheduleTimes : ['03:00']
|
||
}
|
||
|
||
const normalizePhone = (value: unknown) => {
|
||
const text = safeTrim(value)
|
||
if (!text) return null
|
||
return text.replace(/^\+86-?/, '')
|
||
}
|
||
|
||
const parseSourceInt = (value: unknown) => {
|
||
const text = safeTrim(value)
|
||
if (!text) return null
|
||
const num = Number(text)
|
||
return Number.isNaN(num) ? null : num
|
||
}
|
||
|
||
const parseExtensionFlowerName = (extension: unknown) => {
|
||
const text = safeTrim(extension)
|
||
if (!text) return null
|
||
try {
|
||
const parsed = JSON.parse(text)
|
||
const value = parsed?.花名 || parsed?.flowerName
|
||
return safeTrim(value)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
const resolvePersonStatus = (employeeStatus: unknown): 'active' | 'inactive' =>
|
||
resolveSourcePersonStatus(safeTrim(employeeStatus))
|
||
|
||
const resolveDepartmentBinding = (
|
||
sourceDepartmentName: string | null,
|
||
sourceMainDepartmentName: string | null,
|
||
sourceEmployeeStatus: string | null
|
||
): { resolvedDepartmentName: string; departmentSyncBucket: DepartmentSyncBucket } => {
|
||
if (shouldUseSourceDepartmentBinding(sourceEmployeeStatus)) {
|
||
if (sourceDepartmentName) {
|
||
return {
|
||
resolvedDepartmentName: sourceDepartmentName,
|
||
departmentSyncBucket: 'formal'
|
||
}
|
||
}
|
||
|
||
if (sourceMainDepartmentName) {
|
||
return {
|
||
resolvedDepartmentName: sourceMainDepartmentName,
|
||
departmentSyncBucket: 'fallback'
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
resolvedDepartmentName: OTHER_DEPARTMENT_NAME,
|
||
departmentSyncBucket: 'other'
|
||
}
|
||
}
|
||
|
||
const isExcludedSourceDepartment = (value: string | null | undefined) => {
|
||
const name = safeTrim(value)
|
||
if (!name) {
|
||
return false
|
||
}
|
||
|
||
return getExcludedSourceDepartmentNames().has(name)
|
||
}
|
||
|
||
const hasSyncableDepartmentClue = (
|
||
sourceDepartmentName: string | null,
|
||
sourceMainDepartmentName: string | null
|
||
) => {
|
||
const primaryDepartmentName = safeTrim(sourceDepartmentName)
|
||
if (primaryDepartmentName) {
|
||
return !isExcludedSourceDepartment(primaryDepartmentName)
|
||
}
|
||
|
||
const fallbackDepartmentName = safeTrim(sourceMainDepartmentName)
|
||
if (fallbackDepartmentName) {
|
||
return !isExcludedSourceDepartment(fallbackDepartmentName)
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
const shouldSyncPerson = (person: {
|
||
nickname: string
|
||
realName: string
|
||
status: 'active' | 'inactive'
|
||
sourceDepartmentName: string | null
|
||
sourceMainDepartmentName: string | null
|
||
sourceJobNumber: string | null
|
||
}) => {
|
||
if (person.status !== 'active') {
|
||
return false
|
||
}
|
||
|
||
if (!hasSyncableDepartmentClue(person.sourceDepartmentName, person.sourceMainDepartmentName)) {
|
||
return false
|
||
}
|
||
|
||
const excludedNameKeywords = Array.from(
|
||
getConfiguredSet(process.env.HR_SYNC_EXCLUDED_NAME_KEYWORDS || '', '')
|
||
)
|
||
if (excludedNameKeywords.length === 0) {
|
||
return true
|
||
}
|
||
|
||
const text = `${person.nickname} ${person.realName}`.trim()
|
||
return !excludedNameKeywords.some((keyword) => text.includes(keyword))
|
||
}
|
||
|
||
const getSpecialExclusionReason = (person: {
|
||
sourceDepartmentName: string | null
|
||
sourceJobNumber: string | null
|
||
sourcePresence: SourcePresence
|
||
}) => {
|
||
if (person.sourceDepartmentName === '业务支持' && !person.sourceJobNumber) {
|
||
return 'business_support_blank_job_number' as const
|
||
}
|
||
|
||
if (
|
||
person.sourceDepartmentName === '龙晨品牌中心' &&
|
||
person.sourcePresence === 'register_only'
|
||
) {
|
||
return 'longchen_brand_center_register_only' as const
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
const getSourceDepartments = async (pool: any): Promise<SourceDepartment[]> => {
|
||
const schema = getSourceSchema()
|
||
const result = await pool
|
||
.request()
|
||
.query(`
|
||
SELECT
|
||
CAST(NULL AS INT) AS sourceGroupId,
|
||
CAST(NULL AS INT) AS sourceParentGroupId,
|
||
name
|
||
FROM (
|
||
SELECT LTRIM(RTRIM([sys00-dept])) AS name
|
||
FROM ${schema}.hr_employee_register
|
||
UNION
|
||
SELECT LTRIM(RTRIM([sys00-mainDepts])) AS name
|
||
FROM ${schema}.hr_employee_register
|
||
) source_departments
|
||
WHERE ISNULL(name, '') <> ''
|
||
ORDER BY name
|
||
`)
|
||
|
||
return result.recordset.map((row: any) => ({
|
||
sourceGroupId: row.sourceGroupId === null ? null : Number(row.sourceGroupId),
|
||
sourceParentGroupId: row.sourceParentGroupId === null ? null : Number(row.sourceParentGroupId),
|
||
name: String(row.name).trim()
|
||
}))
|
||
}
|
||
|
||
const parseSourceDeptIds = (value: string | null) =>
|
||
(value || '')
|
||
.replace(/[\[\]]/g, '')
|
||
.split(/[|,]/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
.map((item) => Number(item))
|
||
.filter((item) => !Number.isNaN(item))
|
||
|
||
const buildSourcePersonCollection = (
|
||
persons: SourcePerson[],
|
||
specialExclusionSummary: Record<SourcePersonSpecialExclusionReason, number>
|
||
): SourcePersonCollection => {
|
||
const sourcePresenceSummary: Record<SourcePresence, number> = {
|
||
employee_and_register: 0,
|
||
register_only: 0
|
||
}
|
||
const departmentBucketSummary: Record<DepartmentSyncBucket, number> = {
|
||
formal: 0,
|
||
fallback: 0,
|
||
other: 0
|
||
}
|
||
|
||
for (const person of persons) {
|
||
sourcePresenceSummary[person.sourcePresence] += 1
|
||
departmentBucketSummary[person.departmentSyncBucket] += 1
|
||
}
|
||
|
||
return {
|
||
persons,
|
||
sourceMode: getHrSyncSourceMode(),
|
||
sourcePresenceSummary,
|
||
departmentBucketSummary,
|
||
specialExclusionSummary
|
||
}
|
||
}
|
||
|
||
const getSourcePersons = async (pool: any): Promise<SourcePersonCollection> => {
|
||
const schema = getSourceSchema()
|
||
const result = await pool
|
||
.request()
|
||
.query(`
|
||
SELECT
|
||
r.userid AS sourceUserId,
|
||
e.name AS employeeName,
|
||
e.mobile,
|
||
e.email,
|
||
e.title,
|
||
e.dept_id_list,
|
||
e.manager_userid,
|
||
e.extension,
|
||
r.[sys00-name] AS registerName,
|
||
r.[sys00-flowerName] AS flowerName,
|
||
r.[sys02-realName] AS realName,
|
||
r.[sys02-certNo] AS certNo,
|
||
r.[sys00-mobile] AS registerMobile,
|
||
r.[sys00-position] AS registerPosition,
|
||
r.[sys00-jobNumber] AS registerJobNumber,
|
||
e.job_number AS employeeJobNumber,
|
||
r.[sys00-dept] AS registerDept,
|
||
r.[sys00-mainDepts] AS mainDepts,
|
||
r.[sys00-mainDeptId] AS mainDeptId,
|
||
r.[sys00-deptIds] AS registerDeptIds,
|
||
r.[sys01-employeeStatus] AS employeeStatus
|
||
FROM ${schema}.hr_employee_register r
|
||
LEFT JOIN ${schema}.hr_employee e
|
||
ON e.userid = r.userid
|
||
`)
|
||
|
||
const specialExclusionSummary: Record<SourcePersonSpecialExclusionReason, number> = {
|
||
business_support_blank_job_number: 0,
|
||
longchen_brand_center_register_only: 0
|
||
}
|
||
|
||
const persons: SourcePerson[] = []
|
||
|
||
result.recordset.forEach((row: any) => {
|
||
const sourceUserId = safeTrim(row.sourceUserId)
|
||
const sourcePresence: SourcePresence =
|
||
safeTrim(row.employeeName) || safeTrim(row.email) || safeTrim(row.manager_userid)
|
||
? 'employee_and_register'
|
||
: 'register_only'
|
||
const fallbackName =
|
||
safeTrim(row.flowerName) ||
|
||
safeTrim(row.registerName) ||
|
||
safeTrim(row.realName) ||
|
||
safeTrim(row.employeeName) ||
|
||
sourceUserId
|
||
const nickname =
|
||
safeTrim(row.flowerName) ||
|
||
parseExtensionFlowerName(row.extension) ||
|
||
safeTrim(row.employeeName) ||
|
||
fallbackName ||
|
||
'未命名'
|
||
const realName = safeTrim(row.realName) || fallbackName || nickname
|
||
const sourceDepartmentName = safeTrim(row.registerDept)
|
||
const sourceMainDepartmentName = safeTrim(row.mainDepts)
|
||
const sourceJobNumber = safeTrim(row.registerJobNumber) || safeTrim(row.employeeJobNumber)
|
||
const departmentBinding = resolveDepartmentBinding(
|
||
sourceDepartmentName,
|
||
sourceMainDepartmentName,
|
||
safeTrim(row.employeeStatus)
|
||
)
|
||
|
||
const person = {
|
||
sourceUserId: sourceUserId || '',
|
||
nickname,
|
||
realName,
|
||
idNumber: safeTrim(row.certNo),
|
||
sourceDepartmentName,
|
||
sourceMainDepartmentName,
|
||
position: safeTrim(row.registerPosition) || safeTrim(row.title),
|
||
phone: normalizePhone(row.registerMobile) || normalizePhone(row.mobile),
|
||
email: safeTrim(row.email),
|
||
sourceMainDepartmentId: parseSourceInt(row.mainDeptId),
|
||
sourceDeptIds: safeTrim(row.registerDeptIds) || safeTrim(row.dept_id_list),
|
||
sourceJobNumber,
|
||
managerSourceUserId: safeTrim(row.manager_userid),
|
||
sourceEmployeeStatus: safeTrim(row.employeeStatus),
|
||
status: resolvePersonStatus(row.employeeStatus),
|
||
resolvedDepartmentName: departmentBinding.resolvedDepartmentName,
|
||
departmentSyncBucket: departmentBinding.departmentSyncBucket,
|
||
sourcePresence
|
||
} as SourcePerson | (Omit<SourcePerson, 'status'> & { status: 'inactive' })
|
||
|
||
if (!person.sourceUserId) {
|
||
return
|
||
}
|
||
|
||
if (!shouldSyncPerson(person)) {
|
||
return
|
||
}
|
||
|
||
const specialExclusionReason = getSpecialExclusionReason(person)
|
||
if (specialExclusionReason) {
|
||
specialExclusionSummary[specialExclusionReason] += 1
|
||
return
|
||
}
|
||
|
||
if (person.status !== 'active') {
|
||
return
|
||
}
|
||
|
||
persons.push(person as SourcePerson)
|
||
})
|
||
|
||
return buildSourcePersonCollection(persons, specialExclusionSummary)
|
||
}
|
||
|
||
const buildBusinessDepartmentCandidates = (persons: SourcePerson[]): BusinessDepartmentCandidate[] => {
|
||
const map = new Map<string, BusinessDepartmentCandidate>()
|
||
|
||
for (const person of persons) {
|
||
const name = person.resolvedDepartmentName || OTHER_DEPARTMENT_NAME
|
||
const current = map.get(name)
|
||
if (current) {
|
||
current.sourcePersonCount += 1
|
||
if (!current.buckets.includes(person.departmentSyncBucket)) {
|
||
current.buckets.push(person.departmentSyncBucket)
|
||
}
|
||
continue
|
||
}
|
||
|
||
map.set(name, {
|
||
name,
|
||
sourcePersonCount: 1,
|
||
buckets: [person.departmentSyncBucket]
|
||
})
|
||
}
|
||
|
||
if (!map.has(OTHER_DEPARTMENT_NAME)) {
|
||
map.set(OTHER_DEPARTMENT_NAME, {
|
||
name: OTHER_DEPARTMENT_NAME,
|
||
sourcePersonCount: 0,
|
||
buckets: ['other']
|
||
})
|
||
}
|
||
|
||
return Array.from(map.values()).sort((left, right) => departmentCollator.compare(left.name, right.name))
|
||
}
|
||
|
||
const getDepartmentSnapshotMap = async () => {
|
||
const departments = await prisma.department.findMany({
|
||
where: {
|
||
source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM
|
||
},
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
status: true
|
||
}
|
||
})
|
||
|
||
return new Map<string, DepartmentSnapshot>(
|
||
departments.map((department) => [
|
||
department.name,
|
||
{
|
||
id: department.id,
|
||
name: department.name,
|
||
status: department.status
|
||
}
|
||
])
|
||
)
|
||
}
|
||
|
||
const getPersonSnapshotMap = async () => {
|
||
const persons = await prisma.person.findMany({
|
||
where: {
|
||
source_system: HR_SOURCE_SYSTEM,
|
||
source_userid: {
|
||
not: null
|
||
}
|
||
},
|
||
select: {
|
||
id: true,
|
||
source_userid: true,
|
||
nickname: true,
|
||
real_name: true,
|
||
status: true,
|
||
position: true,
|
||
department: true,
|
||
department_sync_bucket: true,
|
||
main_department_id: true,
|
||
mainDepartment: {
|
||
select: {
|
||
id: true,
|
||
name: true
|
||
}
|
||
},
|
||
personDepartments: {
|
||
select: {
|
||
department: {
|
||
select: {
|
||
id: true,
|
||
name: true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
return new Map<string, PersonSnapshot>(
|
||
persons.map((person) => {
|
||
const relatedDepartments = person.personDepartments
|
||
.map((item) => item.department)
|
||
.filter(Boolean)
|
||
|
||
if (
|
||
person.mainDepartment &&
|
||
!relatedDepartments.some((department) => department.id === person.mainDepartment?.id)
|
||
) {
|
||
relatedDepartments.unshift(person.mainDepartment)
|
||
}
|
||
|
||
const departmentIds = Array.from(new Set(relatedDepartments.map((department) => department.id))).sort(
|
||
(left, right) => left - right
|
||
)
|
||
const departmentNames = Array.from(
|
||
new Set(relatedDepartments.map((department) => department.name).filter(Boolean))
|
||
)
|
||
|
||
return [
|
||
person.source_userid as string,
|
||
{
|
||
id: person.id,
|
||
sourceUserId: person.source_userid as string,
|
||
nickname: person.nickname,
|
||
realName: person.real_name,
|
||
status: person.status,
|
||
position: person.position,
|
||
department: person.mainDepartment?.name || person.department || departmentNames[0] || null,
|
||
mainDepartmentId: person.main_department_id,
|
||
mainDepartmentName: person.mainDepartment?.name || person.department || null,
|
||
departmentIds,
|
||
departmentNames,
|
||
departmentSyncBucket: person.department_sync_bucket
|
||
}
|
||
]
|
||
})
|
||
)
|
||
}
|
||
|
||
const getOpenBorrowAssetMap = async () => {
|
||
const borrowRecords = await prisma.borrowRecord.findMany({
|
||
where: {
|
||
return_time: null
|
||
},
|
||
orderBy: [
|
||
{ person_id: 'asc' },
|
||
{ equipment_code: 'asc' }
|
||
],
|
||
select: {
|
||
id: true,
|
||
equipment_id: true,
|
||
person_id: true,
|
||
equipment_code: true,
|
||
equipment: {
|
||
select: {
|
||
equipment_name: true
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
const assetMap = new Map<number, OpenBorrowAssetPreview[]>()
|
||
|
||
for (const record of borrowRecords) {
|
||
const current = assetMap.get(record.person_id) || []
|
||
current.push({
|
||
equipmentId: record.equipment_id,
|
||
borrowRecordId: record.id,
|
||
personId: record.person_id,
|
||
equipmentCode: record.equipment_code,
|
||
equipmentName: record.equipment?.equipment_name || '未命名资产'
|
||
})
|
||
assetMap.set(record.person_id, current)
|
||
}
|
||
|
||
return assetMap
|
||
}
|
||
|
||
const buildSyncChange = (change: HrSyncChangeDraft) => change
|
||
|
||
const diffDepartmentSnapshots = (
|
||
beforeMap: Map<string, DepartmentSnapshot>,
|
||
afterMap: Map<string, DepartmentSnapshot>
|
||
) => {
|
||
const changes: HrSyncChangeDraft[] = []
|
||
const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) =>
|
||
departmentCollator.compare(left, right)
|
||
)
|
||
|
||
for (const key of keys) {
|
||
const before = beforeMap.get(key)
|
||
const after = afterMap.get(key)
|
||
|
||
if (!before && after && after.status === 'active') {
|
||
changes.push(
|
||
buildSyncChange({
|
||
entityType: 'department',
|
||
entityKey: key,
|
||
entityName: after.name,
|
||
changeType: 'department_created',
|
||
title: `新增部门:${after.name}`,
|
||
detail: '该业务部门已进入本次组织同步口径,可直接用于人员归属、资产筛选和盘点任务。',
|
||
beforeSnapshot: null,
|
||
afterSnapshot: after
|
||
})
|
||
)
|
||
continue
|
||
}
|
||
|
||
if (!before || !after) {
|
||
continue
|
||
}
|
||
|
||
if (before.status !== after.status) {
|
||
changes.push(
|
||
buildSyncChange({
|
||
entityType: 'department',
|
||
entityKey: key,
|
||
entityName: after.name,
|
||
changeType: after.status === 'inactive' ? 'department_deactivated' : 'department_reactivated',
|
||
title: `${after.status === 'inactive' ? '部门停用' : '部门重新启用'}:${after.name}`,
|
||
detail:
|
||
after.status === 'inactive'
|
||
? '该业务部门已不在本次同步范围内,系统已自动停用。'
|
||
: '该业务部门重新进入同步范围,系统已重新启用。',
|
||
beforeSnapshot: before,
|
||
afterSnapshot: after
|
||
})
|
||
)
|
||
}
|
||
}
|
||
|
||
return changes
|
||
}
|
||
|
||
const diffPersonSnapshots = (
|
||
beforeMap: Map<string, PersonSnapshot>,
|
||
afterMap: Map<string, PersonSnapshot>,
|
||
openBorrowAssetMap: Map<number, OpenBorrowAssetPreview[]>
|
||
) => {
|
||
const changes: HrSyncChangeDraft[] = []
|
||
const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) =>
|
||
left.localeCompare(right, 'zh-CN')
|
||
)
|
||
|
||
for (const key of keys) {
|
||
const before = beforeMap.get(key)
|
||
const after = afterMap.get(key)
|
||
const currentPerson = after || before
|
||
const personDisplayName = resolveHrSyncPersonDisplayName(currentPerson || {})
|
||
|
||
if (!before && after && after.status === 'active') {
|
||
changes.push(
|
||
buildSyncChange({
|
||
entityType: 'person',
|
||
entityKey: key,
|
||
entityName: after.realName,
|
||
changeType: 'person_created',
|
||
title: `新人入职:${personDisplayName}`,
|
||
detail: `系统归属部门:${formatDepartmentName(after.mainDepartmentName)},岗位:${after.position || '未填写'}。`,
|
||
beforeSnapshot: null,
|
||
afterSnapshot: {
|
||
...after,
|
||
displayName: personDisplayName
|
||
}
|
||
})
|
||
)
|
||
continue
|
||
}
|
||
|
||
if (!before || !after) {
|
||
continue
|
||
}
|
||
|
||
const openBorrowAssets = openBorrowAssetMap.get(after.id || before.id) || []
|
||
|
||
if (before.status !== after.status) {
|
||
changes.push(
|
||
buildSyncChange({
|
||
entityType: 'person',
|
||
entityKey: key,
|
||
entityName: after.realName,
|
||
changeType: after.status === 'inactive' ? 'person_deactivated' : 'person_reactivated',
|
||
title: `${after.status === 'inactive' ? '人员离职/停用' : '人员重新启用'}:${personDisplayName}`,
|
||
detail:
|
||
after.status === 'inactive'
|
||
? `该人员已不在本次同步名单中,请行政确认是否还存在资产交接或借还处理。${
|
||
openBorrowAssets.length > 0
|
||
? ` 当前名下还有 ${openBorrowAssets.length} 件待处理资产,可在下方直接快捷入库。`
|
||
: ' 当前名下没有待处理资产。'
|
||
}`
|
||
: '该人员重新进入同步名单,可继续参与借还与盘点流程。',
|
||
beforeSnapshot: before,
|
||
afterSnapshot: {
|
||
...after,
|
||
displayName: personDisplayName,
|
||
assets: openBorrowAssets
|
||
}
|
||
})
|
||
)
|
||
}
|
||
|
||
if (before.status !== 'active' || after.status !== 'active') {
|
||
continue
|
||
}
|
||
|
||
if (before.mainDepartmentId !== after.mainDepartmentId) {
|
||
changes.push(
|
||
buildSyncChange({
|
||
entityType: 'person',
|
||
entityKey: key,
|
||
entityName: after.realName,
|
||
changeType: 'person_main_department_changed',
|
||
title: `主部门变动:${personDisplayName}`,
|
||
detail: `主部门已从“${formatDepartmentName(before.mainDepartmentName)}”调整为“${formatDepartmentName(after.mainDepartmentName)}”。`,
|
||
beforeSnapshot: before,
|
||
afterSnapshot: {
|
||
...after,
|
||
displayName: personDisplayName
|
||
}
|
||
})
|
||
)
|
||
}
|
||
|
||
if (!areNumberArraysEqual(before.departmentIds, after.departmentIds)) {
|
||
changes.push(
|
||
buildSyncChange({
|
||
entityType: 'person',
|
||
entityKey: key,
|
||
entityName: after.realName,
|
||
changeType: 'person_departments_changed',
|
||
title: `关联部门变动:${personDisplayName}`,
|
||
detail: `关联部门已从“${formatNameList(before.departmentNames)}”调整为“${formatNameList(after.departmentNames)}”。`,
|
||
beforeSnapshot: before,
|
||
afterSnapshot: {
|
||
...after,
|
||
displayName: personDisplayName
|
||
}
|
||
})
|
||
)
|
||
}
|
||
|
||
if (normalizeNullableText(before.position) !== normalizeNullableText(after.position)) {
|
||
changes.push(
|
||
buildSyncChange({
|
||
entityType: 'person',
|
||
entityKey: key,
|
||
entityName: after.realName,
|
||
changeType: 'person_position_changed',
|
||
title: `岗位变动:${personDisplayName}`,
|
||
detail:
|
||
`岗位已从“${before.position || '未填写'}”调整为“${after.position || '未填写'}”。` +
|
||
(openBorrowAssets.length > 0
|
||
? ` 当前名下还有 ${openBorrowAssets.length} 件资产,可在下方直接快捷入库。`
|
||
: ''),
|
||
beforeSnapshot: before,
|
||
afterSnapshot: {
|
||
...after,
|
||
displayName: personDisplayName,
|
||
assets: openBorrowAssets
|
||
}
|
||
})
|
||
)
|
||
}
|
||
}
|
||
|
||
return changes
|
||
}
|
||
|
||
const summarizeHrSyncChanges = (changes: HrSyncChangeDraft[]): HrSyncSummary => {
|
||
const counts = changes.reduce<Record<string, number>>((acc, change) => {
|
||
acc[change.changeType] = (acc[change.changeType] || 0) + (change.countWeight || 1)
|
||
return acc
|
||
}, {})
|
||
|
||
if (changes.length === 0) {
|
||
return {
|
||
title: '组织同步完成',
|
||
content: '本次组织同步已完成,未发现需要行政处理的人员、部门或资产归属异常。',
|
||
counts,
|
||
previewChanges: []
|
||
}
|
||
}
|
||
|
||
const summaryItems = Object.entries(counts)
|
||
.map(([changeType, count]) => ({
|
||
label: CHANGE_TYPE_LABELS[changeType] || changeType,
|
||
count
|
||
}))
|
||
.sort((left, right) => right.count - left.count)
|
||
.map((item) => `${item.label} ${item.count} 项`)
|
||
|
||
const previewChanges = changes.slice(0, 8).map((change) => ({
|
||
title: change.title,
|
||
detail: change.detail,
|
||
change_type: change.changeType
|
||
}))
|
||
|
||
const previewText = previewChanges
|
||
.map((change, index) => `${index + 1}. ${buildHrSyncPreviewLine({
|
||
changeType: change.change_type,
|
||
title: change.title,
|
||
detail: change.detail
|
||
})}`)
|
||
.join('\n')
|
||
|
||
return {
|
||
title: `组织同步发现 ${changes.length} 项变化`,
|
||
content:
|
||
`本次组织同步已完成,共发现 ${changes.length} 项变化。` +
|
||
`\n${summaryItems.join(',')}。` +
|
||
(previewText ? `\n重点变化:\n${previewText}` : ''),
|
||
counts,
|
||
previewChanges
|
||
}
|
||
}
|
||
|
||
const persistHrSyncArtifacts = async ({
|
||
operator,
|
||
triggerMode,
|
||
status,
|
||
result,
|
||
changes,
|
||
errorMessage
|
||
}: {
|
||
operator: string
|
||
triggerMode: SyncTriggerMode
|
||
status: 'success' | 'failed'
|
||
result: HrSyncResult | null
|
||
changes: HrSyncChangeDraft[]
|
||
errorMessage?: string
|
||
}) => {
|
||
const summary =
|
||
status === 'failed'
|
||
? {
|
||
title: '组织同步失败',
|
||
content: `组织同步执行失败,请尽快检查同步配置或源库连接。错误信息:${errorMessage || '未知错误'}`,
|
||
counts: {},
|
||
previewChanges: []
|
||
}
|
||
: summarizeHrSyncChanges(changes)
|
||
|
||
return prisma.$transaction(async (tx) => {
|
||
const syncJob = await tx.syncJob.create({
|
||
data: {
|
||
sync_type: 'hr',
|
||
trigger_mode: triggerMode,
|
||
status,
|
||
operator,
|
||
summary: summary.content,
|
||
result_payload: toJsonText({
|
||
result,
|
||
counts: summary.counts,
|
||
error_message: errorMessage || null,
|
||
changes_count: changes.length
|
||
})
|
||
}
|
||
})
|
||
|
||
if (changes.length > 0) {
|
||
await tx.syncChange.createMany({
|
||
data: changes.map((change) => ({
|
||
sync_job_id: syncJob.id,
|
||
entity_type: change.entityType,
|
||
entity_key: change.entityKey,
|
||
entity_name: change.entityName,
|
||
change_type: change.changeType,
|
||
title: change.title,
|
||
detail: change.detail,
|
||
before_snapshot: toJsonText(change.beforeSnapshot),
|
||
after_snapshot: toJsonText(change.afterSnapshot)
|
||
}))
|
||
})
|
||
}
|
||
|
||
const notification = await tx.notification.create({
|
||
data: {
|
||
title: summary.title,
|
||
content: summary.content,
|
||
type: 'hr_sync',
|
||
level: status === 'failed' ? 'error' : changes.length > 0 ? 'warning' : 'info',
|
||
action_url: '/notifications',
|
||
related_sync_job_id: syncJob.id,
|
||
metadata: toJsonText({
|
||
status,
|
||
changes_count: changes.length,
|
||
counts: summary.counts,
|
||
preview_changes: summary.previewChanges
|
||
})
|
||
}
|
||
})
|
||
|
||
const notificationId: number | null = notification.id
|
||
|
||
const adminUsers = await tx.user.findMany({
|
||
where: {
|
||
role: 'admin',
|
||
status: 'active'
|
||
},
|
||
select: { id: true }
|
||
})
|
||
|
||
if (adminUsers.length > 0) {
|
||
await tx.userNotification.createMany({
|
||
data: adminUsers.map((user) => ({
|
||
user_id: user.id,
|
||
notification_id: notification.id
|
||
})),
|
||
skipDuplicates: true
|
||
})
|
||
}
|
||
|
||
return {
|
||
syncJobId: syncJob.id,
|
||
notificationId,
|
||
summary
|
||
}
|
||
})
|
||
}
|
||
|
||
const syncDepartments = async (sourceDepartments: BusinessDepartmentCandidate[]) => {
|
||
const now = new Date()
|
||
const existing = await prisma.department.findMany({
|
||
where: buildBusinessDepartmentWhere(),
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
source_system: true
|
||
}
|
||
})
|
||
|
||
const existingByName = new Map(existing.map((item) => [item.name, item]))
|
||
let created = 0
|
||
let updated = 0
|
||
|
||
for (const department of sourceDepartments) {
|
||
const matched = existingByName.get(department.name) || null
|
||
const payload = {
|
||
name: department.name,
|
||
status: 'active',
|
||
parent_id: null,
|
||
source_group_id: null,
|
||
source_parent_group_id: null,
|
||
source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM,
|
||
last_synced_at: now
|
||
}
|
||
|
||
if (matched) {
|
||
await prisma.department.update({
|
||
where: { id: matched.id },
|
||
data: payload
|
||
})
|
||
updated += 1
|
||
} else {
|
||
await prisma.department.create({
|
||
data: payload
|
||
})
|
||
created += 1
|
||
}
|
||
}
|
||
|
||
const candidateNames = sourceDepartments.map((item) => item.name)
|
||
const deactivatedResult = await prisma.department.updateMany({
|
||
where: {
|
||
source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM,
|
||
name: {
|
||
notIn: candidateNames
|
||
}
|
||
},
|
||
data: {
|
||
status: 'inactive',
|
||
parent_id: null,
|
||
last_synced_at: now
|
||
}
|
||
})
|
||
|
||
return {
|
||
total: sourceDepartments.length,
|
||
created,
|
||
updated,
|
||
deactivated: deactivatedResult.count
|
||
}
|
||
}
|
||
|
||
const buildLocalDepartmentMap = async (): Promise<LocalDepartmentMap> => {
|
||
const departments = await prisma.department.findMany({
|
||
where: {
|
||
AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }]
|
||
},
|
||
select: {
|
||
id: true,
|
||
name: true
|
||
}
|
||
})
|
||
|
||
return new Map(
|
||
departments.map((item) => [
|
||
item.name,
|
||
{
|
||
id: item.id,
|
||
name: item.name
|
||
}
|
||
])
|
||
)
|
||
}
|
||
|
||
const buildDepartmentNameCandidates = (...values: Array<string | null | undefined>) =>
|
||
Array.from(new Set(values.map((value) => safeTrim(value)).filter(Boolean) as string[]))
|
||
|
||
const cleanupInactiveSyncedPersons = async () => {
|
||
const result = await prisma.person.deleteMany({
|
||
where: {
|
||
source_system: HR_SOURCE_SYSTEM,
|
||
status: 'inactive',
|
||
borrow_records: {
|
||
none: {}
|
||
},
|
||
inventoryTaskParticipants: {
|
||
none: {}
|
||
},
|
||
inventoryTaskItems: {
|
||
none: {}
|
||
}
|
||
}
|
||
})
|
||
|
||
return result.count
|
||
}
|
||
|
||
const cleanupLegacyPersonDepartments = async (localDepartmentMap: LocalDepartmentMap) => {
|
||
const fallbackDepartment = localDepartmentMap.get(OTHER_DEPARTMENT_NAME) || null
|
||
const legacyPersons = await prisma.person.findMany({
|
||
where: {
|
||
source_system: HR_SOURCE_SYSTEM,
|
||
OR: [
|
||
{
|
||
mainDepartment: {
|
||
is: {
|
||
source_system: HR_SOURCE_SYSTEM
|
||
}
|
||
}
|
||
},
|
||
{
|
||
personDepartments: {
|
||
some: {
|
||
department: {
|
||
source_system: HR_SOURCE_SYSTEM
|
||
}
|
||
}
|
||
}
|
||
}
|
||
]
|
||
},
|
||
select: {
|
||
id: true,
|
||
department: true,
|
||
source_department_name: true,
|
||
source_main_department_name: true,
|
||
main_department_id: true,
|
||
mainDepartment: {
|
||
select: {
|
||
id: true,
|
||
name: true
|
||
}
|
||
},
|
||
personDepartments: {
|
||
select: {
|
||
department: {
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
source_system: true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
let remapped = 0
|
||
|
||
for (const person of legacyPersons) {
|
||
const targetDepartment =
|
||
buildDepartmentNameCandidates(
|
||
person.source_department_name,
|
||
person.source_main_department_name,
|
||
person.department,
|
||
person.mainDepartment?.name,
|
||
...person.personDepartments.map((item) => item.department?.name || null)
|
||
)
|
||
.map((name) => localDepartmentMap.get(name) || null)
|
||
.find(Boolean) || fallbackDepartment
|
||
|
||
const nextDepartmentIds = targetDepartment ? [targetDepartment.id] : []
|
||
const currentDepartmentIds = Array.from(
|
||
new Set(
|
||
[
|
||
person.main_department_id,
|
||
...person.personDepartments.map((item) => item.department?.id || null)
|
||
].filter((value): value is number => Number.isInteger(value))
|
||
)
|
||
).sort((left, right) => left - right)
|
||
const normalizedNextDepartmentIds = [...nextDepartmentIds].sort((left, right) => left - right)
|
||
const needsUpdate =
|
||
person.main_department_id !== (targetDepartment?.id || null) ||
|
||
!areNumberArraysEqual(currentDepartmentIds, normalizedNextDepartmentIds) ||
|
||
person.personDepartments.some((item) => item.department?.source_system === HR_SOURCE_SYSTEM)
|
||
|
||
if (!needsUpdate) {
|
||
continue
|
||
}
|
||
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.person.update({
|
||
where: {
|
||
id: person.id
|
||
},
|
||
data: {
|
||
department: targetDepartment?.name || person.department || OTHER_DEPARTMENT_NAME,
|
||
main_department_id: targetDepartment?.id || null
|
||
}
|
||
})
|
||
|
||
await tx.personDepartment.deleteMany({
|
||
where: {
|
||
person_id: person.id
|
||
}
|
||
})
|
||
|
||
if (targetDepartment) {
|
||
await tx.personDepartment.create({
|
||
data: {
|
||
person_id: person.id,
|
||
department_id: targetDepartment.id
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
remapped += 1
|
||
}
|
||
|
||
return remapped
|
||
}
|
||
|
||
const cleanupLegacyDepartments = async () => {
|
||
await prisma.department.updateMany({
|
||
where: {
|
||
OR: [
|
||
{ source_system: HR_SOURCE_SYSTEM },
|
||
{
|
||
source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM,
|
||
status: 'inactive'
|
||
}
|
||
]
|
||
},
|
||
data: {
|
||
parent_id: null
|
||
}
|
||
})
|
||
|
||
const sharedReferenceFilter = {
|
||
equipments: {
|
||
none: {}
|
||
},
|
||
mainPersons: {
|
||
none: {}
|
||
},
|
||
personDepartments: {
|
||
none: {}
|
||
},
|
||
inventoryTasks: {
|
||
none: {}
|
||
},
|
||
inventoryTaskParticipants: {
|
||
none: {}
|
||
},
|
||
inventoryTaskItems: {
|
||
none: {}
|
||
},
|
||
children: {
|
||
none: {}
|
||
}
|
||
}
|
||
|
||
const [legacyResult, inactiveBusinessResult] = await Promise.all([
|
||
prisma.department.deleteMany({
|
||
where: {
|
||
source_system: HR_SOURCE_SYSTEM,
|
||
...sharedReferenceFilter
|
||
}
|
||
}),
|
||
prisma.department.deleteMany({
|
||
where: {
|
||
source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM,
|
||
status: 'inactive',
|
||
...sharedReferenceFilter
|
||
}
|
||
})
|
||
])
|
||
|
||
return {
|
||
deletedLegacyDepartments: legacyResult.count,
|
||
deletedInactiveBusinessDepartments: inactiveBusinessResult.count
|
||
}
|
||
}
|
||
|
||
const resolvePersonDepartments = (person: SourcePerson, localDepartmentMap: LocalDepartmentMap) => {
|
||
const resolvedDepartment = localDepartmentMap.get(person.resolvedDepartmentName) || null
|
||
|
||
return {
|
||
mainDepartmentId: resolvedDepartment?.id || null,
|
||
relatedDepartmentIds: resolvedDepartment ? [resolvedDepartment.id] : [],
|
||
displayDepartment: resolvedDepartment?.name || person.resolvedDepartmentName || OTHER_DEPARTMENT_NAME
|
||
}
|
||
}
|
||
|
||
const resolveUniqueIdNumber = async (
|
||
sourceUserId: string,
|
||
idNumber: string | null,
|
||
targetPersonId?: number | null
|
||
) => {
|
||
if (!idNumber) {
|
||
return null
|
||
}
|
||
|
||
const conflict = await prisma.person.findFirst({
|
||
where: {
|
||
id_number: idNumber,
|
||
NOT: {
|
||
OR: [
|
||
{ source_userid: sourceUserId },
|
||
...(targetPersonId ? [{ id: targetPersonId }] : [])
|
||
]
|
||
}
|
||
},
|
||
select: { id: true }
|
||
})
|
||
|
||
return conflict ? null : idNumber
|
||
}
|
||
|
||
const resolveUniqueJobNumber = async (
|
||
sourceUserId: string,
|
||
jobNumber: string | null,
|
||
targetPersonId?: number | null
|
||
) => {
|
||
if (!jobNumber) {
|
||
return null
|
||
}
|
||
|
||
const conflict = await prisma.person.findFirst({
|
||
where: {
|
||
job_number: jobNumber,
|
||
NOT: {
|
||
OR: [
|
||
{ source_userid: sourceUserId },
|
||
...(targetPersonId ? [{ id: targetPersonId }] : [])
|
||
]
|
||
}
|
||
},
|
||
select: { id: true }
|
||
})
|
||
|
||
return conflict ? null : jobNumber
|
||
}
|
||
|
||
const preserveSyncedIdentifier = (
|
||
nextValue: string | null,
|
||
existingValue: string | null | undefined,
|
||
canPreserve: boolean
|
||
) => {
|
||
if (nextValue) {
|
||
return nextValue
|
||
}
|
||
|
||
if (!canPreserve) {
|
||
return null
|
||
}
|
||
|
||
const normalizedExistingValue = (existingValue || '').trim()
|
||
return normalizedExistingValue || null
|
||
}
|
||
|
||
const syncPersons = async (sourcePersons: SourcePerson[], localDepartmentMap: LocalDepartmentMap) => {
|
||
const now = new Date()
|
||
const existing = await prisma.person.findMany({
|
||
select: {
|
||
id: true,
|
||
source_userid: true,
|
||
job_number: true,
|
||
id_number: true,
|
||
source_system: true
|
||
}
|
||
})
|
||
|
||
const existingBySourceUserId = new Map(
|
||
existing.filter((item) => item.source_userid).map((item) => [item.source_userid as string, item])
|
||
)
|
||
const manualByIdNumber = new Map(
|
||
existing
|
||
.filter((item) => item.id_number && !item.source_userid)
|
||
.map((item) => [item.id_number as string, item])
|
||
)
|
||
|
||
let created = 0
|
||
let updated = 0
|
||
|
||
for (const person of sourcePersons) {
|
||
const matchedBySource = existingBySourceUserId.get(person.sourceUserId) || null
|
||
const matchedManualByIdNumber =
|
||
!matchedBySource && person.idNumber ? manualByIdNumber.get(person.idNumber) || null : null
|
||
const targetPersonId = matchedBySource?.id || matchedManualByIdNumber?.id || null
|
||
const normalizedIdNumber = await resolveUniqueIdNumber(
|
||
person.sourceUserId,
|
||
person.idNumber,
|
||
targetPersonId
|
||
)
|
||
const normalizedJobNumber = await resolveUniqueJobNumber(
|
||
person.sourceUserId,
|
||
person.sourceJobNumber,
|
||
targetPersonId
|
||
)
|
||
const preservedIdNumber = preserveSyncedIdentifier(
|
||
normalizedIdNumber,
|
||
matchedBySource?.id_number,
|
||
Boolean(matchedBySource)
|
||
)
|
||
const preservedJobNumber = preserveSyncedIdentifier(
|
||
normalizedJobNumber,
|
||
matchedBySource?.job_number,
|
||
Boolean(matchedBySource)
|
||
)
|
||
const departmentBinding = resolvePersonDepartments(person, localDepartmentMap)
|
||
let syncedPersonId = targetPersonId || 0
|
||
|
||
const payload = {
|
||
nickname: person.nickname,
|
||
real_name: person.realName,
|
||
job_number: preservedJobNumber,
|
||
id_number: preservedIdNumber,
|
||
department: departmentBinding.displayDepartment,
|
||
main_department_id: departmentBinding.mainDepartmentId,
|
||
position: person.position,
|
||
phone: person.phone,
|
||
email: person.email,
|
||
status: person.status,
|
||
source_userid: person.sourceUserId,
|
||
source_system: HR_SOURCE_SYSTEM,
|
||
source_main_department_id: person.sourceMainDepartmentId,
|
||
source_dept_ids: person.sourceDeptIds,
|
||
source_department_name: person.sourceDepartmentName,
|
||
source_main_department_name: person.sourceMainDepartmentName,
|
||
department_sync_bucket: person.departmentSyncBucket,
|
||
manager_source_userid: person.managerSourceUserId,
|
||
source_employee_status: person.sourceEmployeeStatus,
|
||
last_synced_at: now
|
||
}
|
||
|
||
await prisma.$transaction(async (tx) => {
|
||
if (targetPersonId) {
|
||
await tx.person.update({
|
||
where: { id: targetPersonId },
|
||
data: payload
|
||
})
|
||
} else {
|
||
const createdPerson = await tx.person.create({
|
||
data: payload
|
||
})
|
||
syncedPersonId = createdPerson.id
|
||
}
|
||
|
||
await tx.personDepartment.deleteMany({
|
||
where: { person_id: syncedPersonId }
|
||
})
|
||
|
||
if (departmentBinding.relatedDepartmentIds.length > 0) {
|
||
await tx.personDepartment.createMany({
|
||
data: departmentBinding.relatedDepartmentIds.map((departmentId) => ({
|
||
person_id: syncedPersonId,
|
||
department_id: departmentId
|
||
})),
|
||
skipDuplicates: true
|
||
})
|
||
}
|
||
})
|
||
|
||
if (targetPersonId) {
|
||
updated += 1
|
||
} else {
|
||
created += 1
|
||
}
|
||
|
||
existingBySourceUserId.set(person.sourceUserId, {
|
||
id: syncedPersonId,
|
||
source_userid: person.sourceUserId,
|
||
job_number: preservedJobNumber,
|
||
id_number: preservedIdNumber,
|
||
source_system: HR_SOURCE_SYSTEM
|
||
})
|
||
|
||
if (person.idNumber) {
|
||
manualByIdNumber.delete(person.idNumber)
|
||
}
|
||
}
|
||
|
||
const sourceUserIds = sourcePersons.map((item) => item.sourceUserId)
|
||
const deactivatedResult = await prisma.person.updateMany({
|
||
where: {
|
||
source_system: HR_SOURCE_SYSTEM,
|
||
status: {
|
||
not: 'inactive'
|
||
},
|
||
source_userid: {
|
||
notIn: sourceUserIds
|
||
}
|
||
},
|
||
data: {
|
||
status: 'inactive',
|
||
last_synced_at: now
|
||
}
|
||
})
|
||
|
||
return {
|
||
total: sourcePersons.length,
|
||
created,
|
||
updated,
|
||
deactivated: deactivatedResult.count
|
||
}
|
||
}
|
||
|
||
const migrateEquipmentDepartmentsToBusinessDepartments = async () => {
|
||
const businessDepartments = await prisma.department.findMany({
|
||
where: {
|
||
AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }]
|
||
},
|
||
select: {
|
||
id: true,
|
||
name: true
|
||
}
|
||
})
|
||
|
||
const businessDepartmentIdByName = new Map(businessDepartments.map((item) => [item.name, item.id]))
|
||
const fallbackOtherDepartmentId = businessDepartmentIdByName.get(OTHER_DEPARTMENT_NAME) || null
|
||
const equipments = await prisma.equipment.findMany({
|
||
where: {
|
||
department_id: {
|
||
not: null
|
||
},
|
||
department: {
|
||
is: {
|
||
NOT: [
|
||
{ source_system: null },
|
||
{ source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM }
|
||
]
|
||
}
|
||
}
|
||
},
|
||
select: {
|
||
id: true,
|
||
equipment_code: true,
|
||
equipment_name: true,
|
||
department_id: true,
|
||
department: {
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
source_system: true
|
||
}
|
||
},
|
||
}
|
||
})
|
||
|
||
const updateBucket = new Map<number, number[]>()
|
||
const unresolved: Array<{
|
||
equipment_id: number
|
||
equipment_code: string
|
||
equipment_name: string
|
||
legacy_department_name: string | null
|
||
}> = []
|
||
|
||
for (const equipment of equipments) {
|
||
const legacyDepartmentName = equipment.department?.name || null
|
||
const targetDepartmentId =
|
||
(legacyDepartmentName ? businessDepartmentIdByName.get(legacyDepartmentName) || null : null) ||
|
||
fallbackOtherDepartmentId
|
||
|
||
if (!targetDepartmentId) {
|
||
unresolved.push({
|
||
equipment_id: equipment.id,
|
||
equipment_code: equipment.equipment_code,
|
||
equipment_name: equipment.equipment_name,
|
||
legacy_department_name: legacyDepartmentName
|
||
})
|
||
continue
|
||
}
|
||
|
||
const ids = updateBucket.get(targetDepartmentId) || []
|
||
ids.push(equipment.id)
|
||
updateBucket.set(targetDepartmentId, ids)
|
||
}
|
||
|
||
let migrated = 0
|
||
for (const [targetDepartmentId, equipmentIds] of updateBucket.entries()) {
|
||
const result = await prisma.equipment.updateMany({
|
||
where: {
|
||
id: {
|
||
in: equipmentIds
|
||
}
|
||
},
|
||
data: {
|
||
department_id: targetDepartmentId
|
||
}
|
||
})
|
||
|
||
migrated += result.count
|
||
}
|
||
|
||
return {
|
||
migrated,
|
||
unresolved: unresolved.length,
|
||
samples: unresolved.slice(0, 20)
|
||
}
|
||
}
|
||
|
||
const writeSyncLog = async (result: any, operator = 'system-sync', success = true) => {
|
||
await prisma.operationLog.create({
|
||
data: {
|
||
user_id: null,
|
||
username: operator,
|
||
operation_type: success ? '同步组织与人员' : '同步组织与人员失败',
|
||
operation_details: JSON.stringify(result),
|
||
ip_address: null
|
||
}
|
||
})
|
||
}
|
||
|
||
export const previewHrSync = async () => {
|
||
const pool = await sql.connect(getSqlServerConfig())
|
||
|
||
try {
|
||
const [allDepartments, personCollection] = await Promise.all([
|
||
getSourceDepartments(pool),
|
||
getSourcePersons(pool)
|
||
])
|
||
const businessDepartments = buildBusinessDepartmentCandidates(personCollection.persons)
|
||
|
||
return {
|
||
source_mode: personCollection.sourceMode,
|
||
departments: {
|
||
total: businessDepartments.length,
|
||
raw_total: allDepartments.length,
|
||
sample: businessDepartments.slice(0, 10),
|
||
raw_sample: allDepartments.slice(0, 10)
|
||
},
|
||
persons: {
|
||
total: personCollection.persons.length,
|
||
source_presence_summary: personCollection.sourcePresenceSummary,
|
||
department_bucket_summary: personCollection.departmentBucketSummary,
|
||
special_exclusion_summary: personCollection.specialExclusionSummary,
|
||
sample: personCollection.persons.slice(0, 10)
|
||
}
|
||
}
|
||
} finally {
|
||
await pool.close()
|
||
}
|
||
}
|
||
|
||
export const runHrSync = async (
|
||
operator = 'system-sync',
|
||
triggerMode: SyncTriggerMode = inferTriggerMode(operator)
|
||
) => {
|
||
let pool: any = null
|
||
|
||
try {
|
||
const [beforeDepartmentMap, beforePersonMap] = await Promise.all([
|
||
getDepartmentSnapshotMap(),
|
||
getPersonSnapshotMap()
|
||
])
|
||
|
||
pool = await sql.connect(getSqlServerConfig())
|
||
const personCollection = await getSourcePersons(pool)
|
||
const persons = personCollection.persons
|
||
const businessDepartments = buildBusinessDepartmentCandidates(persons)
|
||
|
||
const departmentResult = await syncDepartments(businessDepartments)
|
||
const localDepartmentMap = await buildLocalDepartmentMap()
|
||
const personResult = await syncPersons(persons, localDepartmentMap)
|
||
const deletedInactivePersons = await cleanupInactiveSyncedPersons()
|
||
const remappedLegacyPersonDepartments = await cleanupLegacyPersonDepartments(localDepartmentMap)
|
||
const equipmentDepartmentMigration = await migrateEquipmentDepartmentsToBusinessDepartments()
|
||
const departmentCleanupResult = await cleanupLegacyDepartments()
|
||
|
||
const result: HrSyncResult = {
|
||
departments: departmentResult,
|
||
persons: personResult,
|
||
equipment_department_migration: equipmentDepartmentMigration,
|
||
cleanup: {
|
||
deleted_inactive_persons: deletedInactivePersons,
|
||
remapped_legacy_person_departments: remappedLegacyPersonDepartments,
|
||
deleted_legacy_departments: departmentCleanupResult.deletedLegacyDepartments,
|
||
deleted_inactive_business_departments: departmentCleanupResult.deletedInactiveBusinessDepartments
|
||
}
|
||
}
|
||
|
||
const [afterDepartmentMap, afterPersonMap, openBorrowAssetMap] = await Promise.all([
|
||
getDepartmentSnapshotMap(),
|
||
getPersonSnapshotMap(),
|
||
getOpenBorrowAssetMap()
|
||
])
|
||
|
||
const changes: HrSyncChangeDraft[] = [
|
||
...diffDepartmentSnapshots(beforeDepartmentMap, afterDepartmentMap),
|
||
...diffPersonSnapshots(beforePersonMap, afterPersonMap, openBorrowAssetMap)
|
||
]
|
||
|
||
if (equipmentDepartmentMigration.unresolved > 0) {
|
||
const sampleText = equipmentDepartmentMigration.samples
|
||
.slice(0, 5)
|
||
.map(
|
||
(item) =>
|
||
`${item.equipment_code} / ${item.equipment_name} / ${item.legacy_department_name || '未设置部门'}`
|
||
)
|
||
.join(';')
|
||
|
||
changes.push(
|
||
buildSyncChange({
|
||
entityType: 'equipment',
|
||
entityKey: 'legacy_department_migration',
|
||
entityName: '资产部门迁移待处理',
|
||
changeType: 'equipment_department_unresolved',
|
||
countWeight: equipmentDepartmentMigration.unresolved,
|
||
title: `${equipmentDepartmentMigration.unresolved} 个资产部门待人工处理`,
|
||
detail:
|
||
`旧叶子部门无法按名称匹配到新的业务部门,请行政核对后手动调整。` +
|
||
(sampleText ? ` 示例:${sampleText}` : ''),
|
||
beforeSnapshot: null,
|
||
afterSnapshot: equipmentDepartmentMigration
|
||
})
|
||
)
|
||
}
|
||
|
||
const artifact = await persistHrSyncArtifacts({
|
||
operator,
|
||
triggerMode,
|
||
status: 'success',
|
||
result,
|
||
changes
|
||
})
|
||
|
||
const finalResult: HrSyncResult = {
|
||
...result,
|
||
sync_job_id: artifact.syncJobId,
|
||
changes_count: changes.length
|
||
}
|
||
|
||
await writeSyncLog(finalResult, operator, true)
|
||
return finalResult
|
||
} catch (error: any) {
|
||
try {
|
||
const artifact = await persistHrSyncArtifacts({
|
||
operator,
|
||
triggerMode,
|
||
status: 'failed',
|
||
result: null,
|
||
changes: [],
|
||
errorMessage: error?.message || String(error)
|
||
})
|
||
|
||
await writeSyncLog(
|
||
{
|
||
sync_job_id: artifact.syncJobId,
|
||
message: error?.message || String(error)
|
||
},
|
||
operator,
|
||
false
|
||
)
|
||
} catch (artifactError) {
|
||
console.error('[hr-sync] persist artifact failed:', artifactError)
|
||
}
|
||
|
||
throw error
|
||
} finally {
|
||
if (pool) {
|
||
await pool.close()
|
||
}
|
||
}
|
||
}
|
||
|
||
export const startHrSyncScheduler = () => {
|
||
if (schedulerStarted) {
|
||
return
|
||
}
|
||
|
||
schedulerStarted = true
|
||
const enabled = process.env.HR_SYNC_ENABLED === 'true'
|
||
if (!enabled) {
|
||
console.log('[hr-sync] scheduler disabled')
|
||
return
|
||
}
|
||
|
||
const syncStatus = getHrSyncConfigStatus()
|
||
if (!syncStatus.complete) {
|
||
console.log(`[hr-sync] scheduler skipped, missing config: ${syncStatus.missingFields.join(',')}`)
|
||
return
|
||
}
|
||
|
||
const scheduleTimes = getConfiguredScheduleTimes()
|
||
const scheduleTimezone = getConfiguredScheduleTimezone()
|
||
setInterval(async () => {
|
||
const now = new Date()
|
||
const currentClock = getCurrentClockInTimeZone(now, scheduleTimezone)
|
||
const currentTime = currentClock.timeKey
|
||
const runKey = `${currentClock.dateKey}@${currentTime}`
|
||
|
||
if (!scheduleTimes.includes(currentTime) || schedulerRunning || lastSchedulerRunKeys.has(runKey)) {
|
||
return
|
||
}
|
||
|
||
schedulerRunning = true
|
||
try {
|
||
await runHrSync('system-scheduler', 'scheduler')
|
||
lastSchedulerRunKeys.add(runKey)
|
||
console.log(
|
||
`[hr-sync] completed at ${now.toISOString()} (schedule_timezone=${scheduleTimezone} ${currentTime})`
|
||
)
|
||
} catch (error) {
|
||
console.error('[hr-sync] failed:', error)
|
||
} finally {
|
||
schedulerRunning = false
|
||
}
|
||
}, 60 * 1000)
|
||
|
||
console.log(
|
||
`[hr-sync] scheduler started, schedule=${scheduleTimes.join(',')}, timezone=${scheduleTimezone}`
|
||
)
|
||
}
|