diff --git a/backend/src/controllers/__tests__/disposalController.state-machine.test.ts b/backend/src/controllers/__tests__/disposalController.state-machine.test.ts index 17636f8..3f2842b 100644 --- a/backend/src/controllers/__tests__/disposalController.state-machine.test.ts +++ b/backend/src/controllers/__tests__/disposalController.state-machine.test.ts @@ -2,7 +2,14 @@ import test from 'node:test' import assert from 'node:assert/strict' import prisma from '../../utils/prisma' -import { approveDisposal, createDisposal } from '../disposalController' +import { + approveDisposal, + buildDisposalAppliedDingTalkContent, + buildDisposalApprovedDingTalkContent, + buildDisposalCompletedDingTalkContent, + buildDisposalRejectedDingTalkContent, + createDisposal +} from '../disposalController' const now = new Date('2026-07-14T03:00:00.000Z') const rootEquipment = { @@ -50,6 +57,62 @@ const createResponse = (role = 'it') => { return { res, state } } +test('报废流程钉钉正文不重复项目标题、时间优先且字段间保留空行', () => { + const cases = [ + { + body: buildDisposalAppliedDingTalkContent({ + appliedAt: now, + equipmentName: '整机', + equipmentCode: 'JS-PC-0010', + applicantName: '申请人', + reason: '设备老化' + }), + labels: ['申请时间', '资产', '申请人', '报废原因'] + }, + { + body: buildDisposalApprovedDingTalkContent({ + approvedAt: now, + equipmentName: '整机', + equipmentCode: 'JS-PC-0010', + approverName: '审批人' + }), + labels: ['审批时间', '资产', '审批人', '审批结果'] + }, + { + body: buildDisposalRejectedDingTalkContent({ + rejectedAt: now, + equipmentName: '整机', + equipmentCode: 'JS-PC-0010', + rejectorName: '驳回人', + reason: '尚可继续使用' + }), + labels: ['驳回时间', '资产', '驳回人', '驳回原因'] + }, + { + body: buildDisposalCompletedDingTalkContent({ + completedAt: now, + equipmentName: '整机', + equipmentCode: 'JS-PC-0010', + completerName: '完成人', + notes: '已环保处置' + }), + labels: ['完成时间', '资产', '完成人', '处置结果', '完成说明'] + } + ] + + for (const item of cases) { + assert.doesNotMatch(item.body, /(^|\n)###\s/) + assert.doesNotMatch(item.body, /【固定资产】/) + const paragraphs = item.body.split('\n\n') + assert.equal(paragraphs.length, item.labels.length + 1) + item.labels.forEach((label, index) => { + assert.match(paragraphs[index], new RegExp(`^\\*\\*${label}:\\*\\* `)) + assert.doesNotMatch(paragraphs[index], /\n/) + }) + assert.match(paragraphs.at(-1) || '', /^> /) + } +}) + test('主资产仍挂载未勾选配件时申请报废返回 409 且不写状态', async () => { const originalTransaction = prisma.$transaction let stateWriteCount = 0 diff --git a/backend/src/controllers/disposalController.ts b/backend/src/controllers/disposalController.ts index 2fc8404..fefe41f 100644 --- a/backend/src/controllers/disposalController.ts +++ b/backend/src/controllers/disposalController.ts @@ -141,6 +141,62 @@ const getResultRecipients = async (tx: Prisma.TransactionClient, applierId: numb const assetNotificationName = (name: unknown, code: unknown) => `${escapeNotificationMarkdownInline(name, '资产')}(${escapeNotificationMarkdownInline(code, '无编码')})` +export const buildDisposalAppliedDingTalkContent = (input: { + appliedAt: Date + equipmentName: unknown + equipmentCode: unknown + applicantName: unknown + reason: unknown +}) => [ + `**申请时间:** ${formatBeijingDateTime(input.appliedAt)}`, + `**资产:** ${assetNotificationName(input.equipmentName, input.equipmentCode)}`, + `**申请人:** ${escapeNotificationMarkdownInline(input.applicantName)}`, + `**报废原因:** ${escapeNotificationMarkdownInline(input.reason)}`, + '> 请核对资产及配件明细后处理。' +].join('\n\n') + +export const buildDisposalApprovedDingTalkContent = (input: { + approvedAt: Date + equipmentName: unknown + equipmentCode: unknown + approverName: unknown +}) => [ + `**审批时间:** ${formatBeijingDateTime(input.approvedAt)}`, + `**资产:** ${assetNotificationName(input.equipmentName, input.equipmentCode)}`, + `**审批人:** ${escapeNotificationMarkdownInline(input.approverName)}`, + '**审批结果:** 已通过', + '> 报废单现已进入待处置阶段。' +].join('\n\n') + +export const buildDisposalRejectedDingTalkContent = (input: { + rejectedAt: Date + equipmentName: unknown + equipmentCode: unknown + rejectorName: unknown + reason: unknown +}) => [ + `**驳回时间:** ${formatBeijingDateTime(input.rejectedAt)}`, + `**资产:** ${assetNotificationName(input.equipmentName, input.equipmentCode)}`, + `**驳回人:** ${escapeNotificationMarkdownInline(input.rejectorName)}`, + `**驳回原因:** ${escapeNotificationMarkdownInline(input.reason)}`, + '> 资产状态已恢复为申请前状态。' +].join('\n\n') + +export const buildDisposalCompletedDingTalkContent = (input: { + completedAt: Date + equipmentName: unknown + equipmentCode: unknown + completerName: unknown + notes: unknown +}) => [ + `**完成时间:** ${formatBeijingDateTime(input.completedAt)}`, + `**资产:** ${assetNotificationName(input.equipmentName, input.equipmentCode)}`, + `**完成人:** ${escapeNotificationMarkdownInline(input.completerName)}`, + '**处置结果:** 已完成报废', + `**完成说明:** ${escapeNotificationMarkdownInline(input.notes, '无')}`, + '> 相关资产现已统一标记为已报废。' +].join('\n\n') + const getCurrentUser = (res: Response) => ({ id: Number(res.locals.user?.id), role: String(res.locals.user?.role || '') @@ -282,16 +338,13 @@ export const createDisposal = async (req: Request, res: Response) => { metadata: { equipment_code: root.equipment_code }, dingTalk: { title: '资产报废待审批', - content: [ - '### 待你审批', - '', - `**资产:** ${assetNotificationName(root.equipment_name, root.equipment_code)}`, - `**申请人:** ${escapeNotificationMarkdownInline(res.locals.user?.name || res.locals.user?.username)}`, - `**申请时间:** ${formatBeijingDateTime(disposal.applied_at)}`, - `**报废原因:** ${escapeNotificationMarkdownInline(disposal_reason)}`, - '', - '> 请核对资产及配件明细后处理。' - ].join('\n'), + content: buildDisposalAppliedDingTalkContent({ + appliedAt: disposal.applied_at, + equipmentName: root.equipment_name, + equipmentCode: root.equipment_code, + applicantName: res.locals.user?.name || res.locals.user?.username, + reason: disposal_reason + }), actionTitle: '立即审批', messageVersion: 'v1' } @@ -484,32 +537,24 @@ export const approveDisposal = async (req: Request, res: Response) => { dingTalk: approved ? { title: '资产报废审批已通过', - content: [ - '### 处理完成', - '', - `**资产:** ${assetNotificationName(rootItem?.equipment.equipment_name, disposal.equipment_code)}`, - '**审批结果:** 已通过', - `**审批人:** ${escapeNotificationMarkdownInline(res.locals.user?.name || res.locals.user?.username)}`, - `**审批时间:** ${formatBeijingDateTime(approvedAt)}`, - '', - '> 报废单现已进入待处置阶段。' - ].join('\n'), + content: buildDisposalApprovedDingTalkContent({ + approvedAt, + equipmentName: rootItem?.equipment.equipment_name, + equipmentCode: disposal.equipment_code, + approverName: res.locals.user?.name || res.locals.user?.username + }), actionTitle: '查看审批结果', messageVersion: 'v1' } : { title: '资产报废申请已驳回', - content: [ - '### 结果已更新', - '', - `**资产:** ${assetNotificationName(rootItem?.equipment.equipment_name, disposal.equipment_code)}`, - '**审批结果:** 已驳回', - `**驳回原因:** ${escapeNotificationMarkdownInline(reject_reason)}`, - `**审批人:** ${escapeNotificationMarkdownInline(res.locals.user?.name || res.locals.user?.username)}`, - `**审批时间:** ${formatBeijingDateTime(approvedAt)}`, - '', - '> 资产状态已恢复为申请前状态。' - ].join('\n'), + content: buildDisposalRejectedDingTalkContent({ + rejectedAt: approvedAt, + equipmentName: rootItem?.equipment.equipment_name, + equipmentCode: disposal.equipment_code, + rejectorName: res.locals.user?.name || res.locals.user?.username, + reason: reject_reason + }), actionTitle: '查看申请', messageVersion: 'v1' } @@ -598,17 +643,13 @@ export const completeDisposal = async (req: Request, res: Response) => { dingTalkRecipients: recipients.dingTalkRecipients, dingTalk: { title: '资产报废处置已完成', - content: [ - '### 处理完成', - '', - `**资产:** ${assetNotificationName(rootItem?.equipment.equipment_name, disposal.equipment_code)}`, - '**处置结果:** 已完成报废', - `**完成人:** ${escapeNotificationMarkdownInline(res.locals.user?.name || res.locals.user?.username)}`, - `**完成时间:** ${formatBeijingDateTime(completedAt)}`, - `**完成说明:** ${escapeNotificationMarkdownInline(completion_notes?.trim() || notes?.trim(), '无')}`, - '', - '> 相关资产现已统一标记为已报废。' - ].join('\n'), + content: buildDisposalCompletedDingTalkContent({ + completedAt, + equipmentName: rootItem?.equipment.equipment_name, + equipmentCode: disposal.equipment_code, + completerName: res.locals.user?.name || res.locals.user?.username, + notes: completion_notes?.trim() || notes?.trim() + }), actionTitle: '查看结果', messageVersion: 'v1' } diff --git a/backend/src/services/__tests__/businessNotificationService.test.ts b/backend/src/services/__tests__/businessNotificationService.test.ts index cdf5df3..58e55e7 100644 --- a/backend/src/services/__tests__/businessNotificationService.test.ts +++ b/backend/src/services/__tests__/businessNotificationService.test.ts @@ -90,7 +90,7 @@ test('业务通知按事件、业务ID、钉钉userId和消息版本生成稳定 dingTalkRecipients: [{ id: 7, person: { source_userid: 'ding-user-7' } }], dingTalk: { title: '资产报废待审批', - content: '### 待你审批\n\n**资产:** 主机(JS-PC-0053)', + content: '**申请时间:** 2026年7月16日 10:00\n\n**资产:** 主机(JS-PC-0053)', actionTitle: '立即审批', messageVersion: 'v1' } @@ -102,7 +102,7 @@ test('业务通知按事件、业务ID、钉钉userId和消息版本生成稳定 assert.equal(deliveryRows[0].recipient_dingtalk_userid, 'ding-user-7') assert.deepEqual(JSON.parse(deliveryRows[0].payload_json), { title: '资产报废待审批', - content: '### 待你审批\n\n**资产:** 主机(JS-PC-0053)', + content: '**申请时间:** 2026年7月16日 10:00\n\n**资产:** 主机(JS-PC-0053)', action_title: '立即审批', action_url: '/workflows/disposals/53', message_version: 'v1' diff --git a/backend/src/services/__tests__/dingtalkDeliveryService.reminder.test.ts b/backend/src/services/__tests__/dingtalkDeliveryService.reminder.test.ts index d55b32f..72f6bb5 100644 --- a/backend/src/services/__tests__/dingtalkDeliveryService.reminder.test.ts +++ b/backend/src/services/__tests__/dingtalkDeliveryService.reminder.test.ts @@ -2,7 +2,10 @@ import test from 'node:test' import assert from 'node:assert/strict' import prisma from '../../utils/prisma' -import { createOverdueDisposalReminders } from '../dingtalkDeliveryService' +import { + buildOverdueDisposalReminderDingTalkContent, + createOverdueDisposalReminders +} from '../dingtalkDeliveryService' const candidate = { id: 71, @@ -12,6 +15,24 @@ const candidate = { reminded_at: null } +test('24小时催办正文不重复项目标题、时间优先且字段间保留空行', () => { + const body = buildOverdueDisposalReminderDingTalkContent({ + appliedAt: candidate.applied_at, + equipmentName: '整机', + equipmentCode: candidate.equipment_code, + applicantName: '申请人' + }) + + assert.doesNotMatch(body, /(^|\n)###\s/) + assert.doesNotMatch(body, /【固定资产】/) + const paragraphs = body.split('\n\n') + assert.deepEqual( + paragraphs.map((paragraph) => paragraph.match(/^\*\*(.+?):\*\*/)?.[1] || '>'), + ['申请时间', '资产', '申请人', '等待时长', '>'] + ) + assert.ok(paragraphs.every((paragraph) => !paragraph.includes('\n'))) +}) + test('24小时催办在无行政时回退通知启用管理员', async () => { const originalFindMany = prisma.disposalRecord.findMany const originalTransaction = prisma.$transaction diff --git a/backend/src/services/dingtalkDeliveryService.ts b/backend/src/services/dingtalkDeliveryService.ts index be61293..9d56d3f 100644 --- a/backend/src/services/dingtalkDeliveryService.ts +++ b/backend/src/services/dingtalkDeliveryService.ts @@ -378,6 +378,19 @@ const refreshOneDeliveryResult = async () => { } } +export const buildOverdueDisposalReminderDingTalkContent = (input: { + appliedAt: Date + equipmentName: unknown + equipmentCode: unknown + applicantName: unknown +}) => [ + `**申请时间:** ${formatBeijingDateTime(input.appliedAt)}`, + `**资产:** ${escapeNotificationMarkdownInline(input.equipmentName, '资产')}(${escapeNotificationMarkdownInline(input.equipmentCode, '无编码')})`, + `**申请人:** ${escapeNotificationMarkdownInline(input.applicantName)}`, + '**等待时长:** 已超过24小时', + '> 请核对资产及配件明细后尽快处理。' +].join('\n\n') + export const createOverdueDisposalReminders = async ( rollout: DingTalkRecipientRollout = getCurrentDingTalkRecipientRollout() ) => { @@ -427,8 +440,6 @@ export const createOverdueDisposalReminders = async ( detail: '提交满24小时仍未审批,系统自动催办一次' } }) - const assetName = `${escapeNotificationMarkdownInline(candidate.equipment?.equipment_name, '资产')}(${escapeNotificationMarkdownInline(candidate.equipment_code, '无编码')})` - const applierName = escapeNotificationMarkdownInline(candidate.applier?.name || candidate.applier?.username) await createBusinessNotification(tx, { eventType: 'disposal_overdue_reminder', businessType: 'disposal', @@ -441,16 +452,12 @@ export const createOverdueDisposalReminders = async ( dingTalkRecipients, dingTalk: { title: '资产报废待审批', - content: [ - '### 待你审批', - '', - `**资产:** ${assetName}`, - `**申请人:** ${applierName}`, - `**申请时间:** ${formatBeijingDateTime(candidate.applied_at)}`, - `**等待时长:** 已超过24小时`, - '', - '> 请核对资产及配件明细后尽快处理。' - ].join('\n'), + content: buildOverdueDisposalReminderDingTalkContent({ + appliedAt: candidate.applied_at, + equipmentName: candidate.equipment?.equipment_name, + equipmentCode: candidate.equipment_code, + applicantName: candidate.applier?.name || candidate.applier?.username + }), actionTitle: '立即审批', messageVersion: 'reminder-v1' }