优化花名册变动通知排版与IT路由
This commit is contained in:
parent
a65de1d52f
commit
f670ccb5d5
@ -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|笔记本'
|
||||
])
|
||||
})
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user