GuDingZiChan/backend/src/services/hrSyncService.ts
WuYongJie aa0f27dce0 feat(组织同步): 上游业务库迁移到报表库, schema 改为可配置
- hrSyncService: 新增 SQLSERVER_SCHEMA(默认 rpt, 带格式校验防注入)
- 3 处 dbo.hr_employee_register/hr_employee 改为动态 ${schema}.表名
- .env.example / docker-compose.yml 注入 SQLSERVER_SCHEMA
- 配合上游通知: 老业务库迁到 JSReportReplica.rpt, 只读使用
2026-06-25 13:27:10 +08:00

1978 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 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}`
)
}