From f670ccb5d51e8bf79d692ffaa0e2e3cb7e1b1c5b Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 17 Jul 2026 13:13:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=8A=B1=E5=90=8D=E5=86=8C?= =?UTF-8?q?=E5=8F=98=E5=8A=A8=E9=80=9A=E7=9F=A5=E6=8E=92=E7=89=88=E4=B8=8E?= =?UTF-8?q?IT=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/__tests__/hrSyncService.test.ts | 75 +++++- backend/src/services/hrSyncHelpers.ts | 221 +++++++++++++++++- backend/src/services/hrSyncService.ts | 23 +- 3 files changed, 300 insertions(+), 19 deletions(-) diff --git a/backend/src/services/__tests__/hrSyncService.test.ts b/backend/src/services/__tests__/hrSyncService.test.ts index a7f6270..efba76b 100644 --- a/backend/src/services/__tests__/hrSyncService.test.ts +++ b/backend/src/services/__tests__/hrSyncService.test.ts @@ -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|笔记本' + ]) }) diff --git a/backend/src/services/hrSyncHelpers.ts b/backend/src/services/hrSyncHelpers.ts index a8f6a90..e2acaa7 100644 --- a/backend/src/services/hrSyncHelpers.ts +++ b/backend/src/services/hrSyncHelpers.ts @@ -12,6 +12,16 @@ export type OpenBorrowAssetPreview = { equipmentName: string } +export type HrSyncNotificationChange = { + entityName: string + changeType: string + title: string + detail: string + countWeight?: number + beforeSnapshot?: Record | null + afterSnapshot?: Record | null +} + type HrSyncPersonDisplayNameSource = { nickname?: string | null realName?: string | null @@ -102,6 +112,148 @@ const extractStructuredBorrowedAssets = (afterSnapshot?: Record | 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 | null | undefined, + key: string, + fallback: string +) => safeTrim(snapshot?.[key]) || fallback + +const getSnapshotTextList = ( + snapshot: Record | 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> + : [] + 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') } diff --git a/backend/src/services/hrSyncService.ts b/backend/src/services/hrSyncService.ts index 2419498..50f712a 100644 --- a/backend/src/services/hrSyncService.ts +++ b/backend/src/services/hrSyncService.ts @@ -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