优化花名册变动通知排版与IT路由

This commit is contained in:
WuYongJie 2026-07-17 13:13:20 +08:00
parent a65de1d52f
commit f670ccb5d5
3 changed files with 300 additions and 19 deletions

View File

@ -5,6 +5,7 @@ import {
buildHrSyncPreviewLine,
buildHrSyncDingTalkContent,
formatBorrowedAssetSummary,
resolveHrSyncNotificationRoleRoute,
resolveHrSyncPersonDisplayName,
resolveSourcePersonStatus,
shouldNotifyHrSync,
@ -131,21 +132,85 @@ test('buildHrSyncPreviewLine 在没有结构化资产时仍兼容旧的详情文
assert.equal(line, '岗位变动:悟空(名下资产:JS-PC-2001 / 主机)')
})
test('花名册同步无变化只保存日志,有变化或失败才通知管理员', () => {
test('花名册同步无变化只保存日志,有变化或失败才发送通知', () => {
assert.equal(shouldNotifyHrSync('success', 0), false)
assert.equal(shouldNotifyHrSync('success', 2), true)
assert.equal(shouldNotifyHrSync('failed', 0), true)
})
test('花名册钉钉汇总按时间、结果和待处理内容排版', () => {
test('花名册同步变动通知 IT,无可用 IT 时回退管理员;同步失败直接通知管理员', () => {
assert.deepEqual(resolveHrSyncNotificationRoleRoute('success'), {
primaryRoles: ['it'],
fallbackRoles: ['admin']
})
assert.deepEqual(resolveHrSyncNotificationRoleRoute('failed'), {
primaryRoles: ['admin'],
fallbackRoles: []
})
})
test('花名册钉钉汇总按变动类型分组,并让每件资产独占一行', () => {
const content = buildHrSyncDingTalkContent({
status: 'success',
occurredAt: new Date('2026-07-16T02:00:00.000Z'),
changesCount: 3,
summary: '新增人员 2 项,部门变化 1 项。'
summary: '新人入职 1 项,岗位变动 1 项,人员离职/停用 1 项。',
changes: [
{
entityName: '小满',
changeType: 'person_created',
title: '新人入职:小满',
detail: '系统归属部门:产品部,岗位:产品经理。',
afterSnapshot: {
displayName: '小满',
mainDepartmentName: '产品部',
position: '产品经理'
}
},
{
entityName: '范闲',
changeType: 'person_position_changed',
title: '岗位变动:范闲',
detail: '岗位发生变化。',
beforeSnapshot: { position: '摄影师' },
afterSnapshot: {
displayName: '范闲',
position: '导演',
assets: [
{ equipmentCode: 'JS-PC-1001', equipmentName: '主机' },
{ equipmentCode: 'JS-MON-2001', equipmentName: '显示器' }
]
}
},
{
entityName: '旭尧',
changeType: 'person_deactivated',
title: '人员离职/停用:旭尧',
detail: '人员离职。',
afterSnapshot: {
displayName: '旭尧',
assets: [
{ equipmentCode: 'JS-NB-3001', equipmentName: '笔记本' }
]
}
}
]
})
assert.match(content, /\*\*同步时间:\*\* 2026年7月16日 10:00/)
assert.match(content, /\*\*执行结果:\*\* 发现 3 项变化,需要管理员核对/)
assert.match(content, /\*\*待处理内容:\*\* 新增人员 2 项,部门变化 1 项。/)
assert.match(content, /\*\*执行结果:\*\* 发现 3 项变化,需要 IT 核对/)
assert.match(content, /\*\*变动概览:\*\* 新入职 1 项|岗位变动 1 项|离职 \/ 停用 1 项/)
assert.match(content, /\*\*新入职(1)\*\*/)
assert.match(content, /部门:产品部/)
assert.match(content, /\*\*岗位变动(1)\*\*/)
assert.match(content, /岗位:摄影师 → 导演/)
assert.match(content, /- JS\\-PC\\-1001|主机\n- JS\\-MON\\-2001|显示器/)
assert.match(content, /\*\*离职 \/ 停用(1)\*\*/)
assert.match(content, /- JS\\-NB\\-3001|笔记本/)
const assetLines = content.split('\n').filter((line) => line.startsWith('- JS'))
assert.deepEqual(assetLines, [
'- JS\\-PC\\-1001|主机',
'- JS\\-MON\\-2001|显示器',
'- JS\\-NB\\-3001|笔记本'
])
})

View File

@ -12,6 +12,16 @@ export type OpenBorrowAssetPreview = {
equipmentName: string
}
export type HrSyncNotificationChange = {
entityName: string
changeType: string
title: string
detail: string
countWeight?: number
beforeSnapshot?: Record<string, any> | null
afterSnapshot?: Record<string, any> | null
}
type HrSyncPersonDisplayNameSource = {
nickname?: string | null
realName?: string | null
@ -102,6 +112,148 @@ const extractStructuredBorrowedAssets = (afterSnapshot?: Record<string, any> | n
.filter((asset) => asset.equipmentCode && asset.equipmentName)
}
const HR_SYNC_NOTIFICATION_GROUPS = [
{ changeType: 'person_created', label: '新入职' },
{ changeType: 'person_position_changed', label: '岗位变动' },
{ changeType: 'person_main_department_changed', label: '主部门变动' },
{ changeType: 'person_departments_changed', label: '关联部门变动' },
{ changeType: 'person_deactivated', label: '离职 / 停用' },
{ changeType: 'person_reactivated', label: '重新启用' },
{ changeType: 'department_created', label: '新增部门' },
{ changeType: 'department_deactivated', label: '部门停用' },
{ changeType: 'department_reactivated', label: '部门重新启用' },
{ changeType: 'equipment_department_unresolved', label: '资产部门待处理' }
] as const
const DINGTALK_CONTENT_LIMIT = 4300
const DINGTALK_ASSET_PREVIEW_LIMIT = 20
const getSnapshotText = (
snapshot: Record<string, any> | null | undefined,
key: string,
fallback: string
) => safeTrim(snapshot?.[key]) || fallback
const getSnapshotTextList = (
snapshot: Record<string, any> | null | undefined,
key: string
) => {
const values = Array.isArray(snapshot?.[key]) ? snapshot?.[key] : []
const names = values.map(safeTrim).filter((value): value is string => Boolean(value))
return names.length > 0 ? names.join('、') : '未设置'
}
const getChangeDisplayName = (change: HrSyncNotificationChange) =>
getSnapshotText(
change.afterSnapshot,
'displayName',
getSnapshotText(change.beforeSnapshot, 'displayName', change.entityName || '未命名')
)
const buildAssetLines = (change: HrSyncNotificationChange) => {
const assets = extractStructuredBorrowedAssets(change.afterSnapshot)
if (assets.length === 0) {
return ['- 名下资产:无']
}
const lines = assets.slice(0, DINGTALK_ASSET_PREVIEW_LIMIT).map((asset) =>
`- ${escapeNotificationMarkdownInline(asset.equipmentCode)}|${escapeNotificationMarkdownInline(asset.equipmentName)}`
)
if (assets.length > DINGTALK_ASSET_PREVIEW_LIMIT) {
lines.push(`- 其余 ${assets.length - DINGTALK_ASSET_PREVIEW_LIMIT} 件资产请在系统中查看`)
}
return lines
}
const buildUnresolvedEquipmentLines = (change: HrSyncNotificationChange) => {
const samples = Array.isArray(change.afterSnapshot?.samples)
? change.afterSnapshot?.samples as Array<Record<string, any>>
: []
const lines = samples.slice(0, DINGTALK_ASSET_PREVIEW_LIMIT).map((sample) => {
const code = getSnapshotText(sample, 'equipment_code', '无编码')
const name = getSnapshotText(sample, 'equipment_name', '未命名资产')
const department = getSnapshotText(sample, 'legacy_department_name', '未设置部门')
return `- ${escapeNotificationMarkdownInline(code)}|${escapeNotificationMarkdownInline(name)}|原部门:${escapeNotificationMarkdownInline(department)}`
})
return lines.length > 0 ? lines : ['- 请进入系统查看无法匹配部门的资产']
}
const buildHrSyncChangeBlock = (change: HrSyncNotificationChange, index: number) => {
const before = change.beforeSnapshot
const after = change.afterSnapshot
const displayName = escapeNotificationMarkdownInline(getChangeDisplayName(change))
const heading = `**${index}. ${displayName}**`
switch (change.changeType) {
case 'person_created':
return [
heading,
`部门:${escapeNotificationMarkdownInline(getSnapshotText(after, 'mainDepartmentName', getSnapshotText(after, 'department', '未设置')))}`,
`岗位:${escapeNotificationMarkdownInline(getSnapshotText(after, 'position', '未填写'))}`
].join('\n\n')
case 'person_position_changed':
return [
heading,
`岗位:${escapeNotificationMarkdownInline(getSnapshotText(before, 'position', '未填写'))} → ${escapeNotificationMarkdownInline(getSnapshotText(after, 'position', '未填写'))}`,
'名下资产:',
buildAssetLines(change).join('\n')
].join('\n\n')
case 'person_main_department_changed':
return [
heading,
`主部门:${escapeNotificationMarkdownInline(getSnapshotText(before, 'mainDepartmentName', getSnapshotText(before, 'department', '未设置')))} → ${escapeNotificationMarkdownInline(getSnapshotText(after, 'mainDepartmentName', getSnapshotText(after, 'department', '未设置')))}`
].join('\n\n')
case 'person_departments_changed':
return [
heading,
`关联部门:${escapeNotificationMarkdownInline(getSnapshotTextList(before, 'departmentNames'))} → ${escapeNotificationMarkdownInline(getSnapshotTextList(after, 'departmentNames'))}`
].join('\n\n')
case 'person_deactivated':
return [
heading,
'状态:离职 / 停用',
'名下资产:',
buildAssetLines(change).join('\n'),
'处理建议:核对未归还资产并完成交接'
].join('\n\n')
case 'person_reactivated':
return [
heading,
'状态:重新启用',
`部门:${escapeNotificationMarkdownInline(getSnapshotText(after, 'mainDepartmentName', getSnapshotText(after, 'department', '未设置')))}`,
`岗位:${escapeNotificationMarkdownInline(getSnapshotText(after, 'position', '未填写'))}`
].join('\n\n')
case 'department_created':
return [heading, '状态:新增部门'].join('\n\n')
case 'department_deactivated':
return [heading, '状态:部门停用'].join('\n\n')
case 'department_reactivated':
return [heading, '状态:部门重新启用'].join('\n\n')
case 'equipment_department_unresolved':
return [
heading,
`待处理:${change.countWeight || 1} 件资产无法匹配业务部门`,
buildUnresolvedEquipmentLines(change).join('\n')
].join('\n\n')
default:
return [heading, escapeNotificationMarkdownInline(change.detail)].join('\n\n')
}
}
const groupHrSyncNotificationChanges = (changes: HrSyncNotificationChange[]) => {
const knownTypes = new Set(HR_SYNC_NOTIFICATION_GROUPS.map((group) => group.changeType as string))
const groups = HR_SYNC_NOTIFICATION_GROUPS.map((group) => ({
...group,
changes: changes.filter((change) => change.changeType === group.changeType)
})).filter((group) => group.changes.length > 0)
const otherChanges = changes.filter((change) => !knownTypes.has(change.changeType))
return otherChanges.length > 0
? [...groups, { changeType: 'other', label: '其他变动', changes: otherChanges }]
: groups
}
export const buildHrSyncPreviewLine = (change: {
changeType: string
title: string
@ -126,23 +278,76 @@ export const shouldNotifyHrSync = (
changesCount: number
) => status === 'failed' || changesCount > 0
export const resolveHrSyncNotificationRoleRoute = (
status: 'success' | 'failed'
) => status === 'failed'
? { primaryRoles: ['admin'], fallbackRoles: [] }
: { primaryRoles: ['it'], fallbackRoles: ['admin'] }
export const buildHrSyncDingTalkContent = (input: {
status: 'success' | 'failed'
occurredAt: Date
changesCount: number
summary: string
changes?: HrSyncNotificationChange[]
}) => {
const resultText = input.status === 'failed'
? '同步失败,需要管理员处理'
: `发现 ${input.changesCount} 项变化,需要管理员核对`
const summary = input.status === 'failed'
? '请进入固定资产系统查看错误详情,并检查上游连接或同步配置。'
: compactNotificationText(input.summary, '请进入系统查看本次变化明细。', 300)
: `发现 ${input.changesCount} 项变化,需要 IT 核对`
if (input.status === 'failed') {
return [
`**同步时间:** ${formatBeijingDateTime(input.occurredAt)}`,
`**执行结果:** ${escapeNotificationMarkdownInline(resultText)}`,
'**待处理内容:** 请检查上游连接、同步配置及错误详情。',
'> 请管理员进入系统处理同步异常。'
].join('\n\n')
}
return [
const changes = input.changes || []
const groups = groupHrSyncNotificationChanges(changes)
const overview = groups
.map((group) => {
const count = group.changes.reduce((total, change) => total + (change.countWeight || 1), 0)
return `${group.label} ${count} 项`
})
.join('|')
const headerBlocks = [
`**同步时间:** ${formatBeijingDateTime(input.occurredAt)}`,
`**执行结果:** ${escapeNotificationMarkdownInline(resultText)}`,
`**待处理内容:** ${escapeNotificationMarkdownInline(summary)}`,
'> 这是一条需要人工关注的花名册同步汇总。'
].join('\n\n')
`**变动概览:** ${escapeNotificationMarkdownInline(overview || compactNotificationText(input.summary, '请进入系统查看本次变化明细。', 300))}`
]
const detailBlocks: string[] = []
let shownChanges = 0
for (const group of groups) {
const groupBlocks: string[] = []
for (const [index, change] of group.changes.entries()) {
const block = buildHrSyncChangeBlock(change, index + 1)
const candidate = [
...headerBlocks,
...detailBlocks,
`**${group.label}(${group.changes.reduce((total, item) => total + (item.countWeight || 1), 0)})**`,
...groupBlocks,
block,
'> 请逐项核对人员状态及名下资产;完整明细可在系统内查看。'
].join('\n\n')
if (candidate.length > DINGTALK_CONTENT_LIMIT) {
break
}
groupBlocks.push(block)
shownChanges += 1
}
if (groupBlocks.length > 0) {
const count = group.changes.reduce((total, change) => total + (change.countWeight || 1), 0)
detailBlocks.push(`**${group.label}(${count})**`, ...groupBlocks)
}
}
const omittedChanges = Math.max(0, changes.length - shownChanges)
const footer = omittedChanges > 0
? `> 另有 ${omittedChanges} 条变动未在消息中展开,请进入系统查看完整明细。`
: '> 请逐项核对人员状态及名下资产;完整明细可在系统内查看。'
return [...headerBlocks, ...detailBlocks, footer].join('\n\n')
}

View File

@ -11,6 +11,7 @@ import {
buildHrSyncPreviewLine,
buildHrSyncDingTalkContent,
resolveHrSyncPersonDisplayName,
resolveHrSyncNotificationRoleRoute,
resolveSourcePersonStatus,
shouldNotifyHrSync,
shouldUseSourceDepartmentBinding,
@ -1122,8 +1123,17 @@ const persistHrSyncArtifacts = async ({
let notificationId: number | null = null
if (shouldNotifyHrSync(status, changes.length)) {
const stationRecipients = await findActiveStationUsersByRoles(tx, ['admin'])
const dingTalkRecipients = await findActiveDingTalkUsersByRoles(tx, ['admin'])
const roleRoute = resolveHrSyncNotificationRoleRoute(status)
let stationRecipients = await findActiveStationUsersByRoles(tx, roleRoute.primaryRoles)
if (stationRecipients.length === 0 && roleRoute.fallbackRoles.length > 0) {
stationRecipients = await findActiveStationUsersByRoles(tx, roleRoute.fallbackRoles)
}
let dingTalkRecipients = await findActiveDingTalkUsersByRoles(tx, roleRoute.primaryRoles)
if (dingTalkRecipients.length === 0 && roleRoute.fallbackRoles.length > 0) {
dingTalkRecipients = await findActiveDingTalkUsersByRoles(tx, roleRoute.fallbackRoles)
}
const notification = await createBusinessNotification(tx, {
eventType: status === 'failed' ? 'hr_sync_failed' : 'hr_sync_attention',
businessType: 'hr_sync',
@ -1142,15 +1152,16 @@ const persistHrSyncArtifacts = async ({
stationRecipients,
dingTalkRecipients,
dingTalk: {
title: status === 'failed' ? '花名册同步失败' : '花名册同步待处理',
title: status === 'failed' ? '花名册同步失败' : '花名册同步变动',
content: buildHrSyncDingTalkContent({
status,
occurredAt: new Date(),
changesCount: changes.length,
summary: summary.content
summary: summary.content,
changes
}),
actionTitle: '查看同步结果',
messageVersion: 'summary-v1'
actionTitle: '查看变动明细',
messageVersion: 'structured-v2'
}
})
notificationId = notification?.id ?? null