From ad0e130149a0799395fcb0bdcd60c8beb1f940d0 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 9 Nov 2026 13:34:52 +0800 Subject: [PATCH] feat: sync borrowed asset departments and notify HR outcomes --- .../hrBorrowDepartment.integration.test.ts | 271 +++++++++ .../hrDepartureReturn.integration.test.ts | 2 +- .../services/__tests__/hrSyncService.test.ts | 40 +- backend/src/services/hrAssetOutcomeStore.ts | 47 ++ .../src/services/hrBorrowDepartmentService.ts | 169 ++++++ .../src/services/hrDepartureReturnService.ts | 62 ++- backend/src/services/hrSyncHelpers.ts | 60 +- backend/src/services/hrSyncService.ts | 183 ++++-- .../scripts/test-notification-quick-return.ts | 76 --- frontend/src/types/index.ts | 1 + .../notifications/NotificationCenter.vue | 525 +++--------------- .../notifications/notificationQuickReturn.ts | 22 - 12 files changed, 813 insertions(+), 645 deletions(-) create mode 100644 backend/src/services/__tests__/hrBorrowDepartment.integration.test.ts create mode 100644 backend/src/services/hrAssetOutcomeStore.ts create mode 100644 backend/src/services/hrBorrowDepartmentService.ts delete mode 100644 frontend/scripts/test-notification-quick-return.ts delete mode 100644 frontend/src/views/notifications/notificationQuickReturn.ts diff --git a/backend/src/services/__tests__/hrBorrowDepartment.integration.test.ts b/backend/src/services/__tests__/hrBorrowDepartment.integration.test.ts new file mode 100644 index 0000000..a8e42df --- /dev/null +++ b/backend/src/services/__tests__/hrBorrowDepartment.integration.test.ts @@ -0,0 +1,271 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { randomUUID } from 'node:crypto' +import type { Equipment, Person, Prisma } from '@prisma/client' + +test('花名册借用资产归属同步在隔离 MySQL 验证', { + skip: process.env.RUN_BUSINESS_DB_TESTS !== 'true' +}, async (t) => { + const url = new URL(process.env.DATABASE_URL || '') + assert.ok(url.protocol === 'mysql:' && ['127.0.0.1', 'localhost'].includes(url.hostname) + && url.port === '13309' && url.pathname === '/equipment_audit' && !url.search && !url.hash, + '只允许本机隔离 equipment_audit 测试库') + const prisma = (await import('../../utils/prisma')).default + const { runHrSync } = await import('../hrSyncService') + const { stageHrBorrowDepartmentChange, applyHrBorrowDepartmentChanges } = await import('../hrBorrowDepartmentService') + const sql = (await import('mssql')).default + const prefix = `hr-dept-${randomUUID().slice(0, 12)}` + const equipmentIds: number[] = [] + const personIds: number[] = [] + const departmentIds: number[] = [] + const storageIds: number[] = [] + const userIds: number[] = [] + const fixtureTime = new Date(Date.now() - 3 * 60 * 60 * 1000) + let sequence = 0 + try { + const department = async (name: string) => { + const row = await prisma.department.create({ data: { name: `${prefix}-${name}`, status: 'active', source_system: 'ding_hr_business' } }) + departmentIds.push(row.id) + return row + } + const oldDepartment = await department('旧部门') + const newDepartment = await department('新部门') + const otherDepartment = await department('其他部门') + const storage = await prisma.storageLocation.create({ data: { name: prefix } }); storageIds.push(storage.id) + const person = async (departmentId = oldDepartment.id) => { + const id = `${prefix}-p${++sequence}` + const row = await prisma.person.create({ data: { nickname: id, real_name: id, source_userid: id, + source_system: 'ding_hr', source_employee_status: '2', status: 'active', main_department_id: departmentId } }) + personIds.push(row.id) + return row + } + const equipment = async (extra: Partial = {}) => { + const row = await prisma.equipment.create({ data: { + equipment_code: `${prefix}-a${++sequence}`, equipment_name: '测试设备', equipment_type: '人事同步测试', + status: '已借出', total_quantity: 1, remaining_quantity: 0, department_id: oldDepartment.id, + storage_location_id: storage.id, unit_price: 100, borrowed_value: 100, total_value: 100, ...extra + } }) + equipmentIds.push(row.id) + return row + } + const loan = (root: Equipment, borrower: Person, childrenSnapshot?: string) => prisma.borrowRecord.create({ data: { + equipment_id: root.id, equipment_code: root.equipment_code, person_id: borrower.id, + person_name: borrower.real_name, person_nickname: borrower.nickname, + status: '已借出', borrow_time: fixtureTime, created_at: fixtureTime, children_snapshot: childrenSnapshot + } }) + const stage = (borrower: Person, borrowRecordIds?: number[]) => prisma.$transaction(async tx => { + await tx.$queryRaw`SELECT id FROM person WHERE id = ${borrower.id} FOR UPDATE` + await stageHrBorrowDepartmentChange(tx, { personId: borrower.id, personName: borrower.real_name, + sourceUserId: borrower.source_userid!, fromDepartmentId: oldDepartment.id, + toDepartmentId: newDepartment.id, departmentName: newDepartment.name }, borrowRecordIds) + await tx.person.update({ where: { id: borrower.id }, data: { main_department_id: newDepartment.id } }) + }) + const flowCount = (id: number) => prisma.assetTransferRecord.count({ where: { root_equipment_id: id } }) + + await t.test('资产结果跨无收件和通知事务失败保留,恢复后 IT 站内与钉钉各只通知一次', async () => { + const moved = await person() + const departed = await person() + const itPerson = await person() + const it = await prisma.user.create({ data: { username: `${prefix}-it`, name: '测试IT', password: 'local-only', role: 'it', status: 'inactive', person_id: itPerson.id } }) + userIds.push(it.id) + const admin = await prisma.user.create({ data: { username: `${prefix}-admin`, name: '测试管理员', password: 'local-only', role: 'admin', status: 'inactive' } }) + userIds.push(admin.id) + const root = await equipment() + const child = await equipment({ parent_id: root.id, mount_status: 'mounted' }) + const movedLoan = await loan(root, moved, JSON.stringify([{ id: child.id, parent_id: root.id }])) + const returned = await equipment() + const returnedLoan = await loan(returned, departed) + const blockedDeparture = await equipment() + await equipment({ parent_id: blockedDeparture.id, mount_status: 'mounted' }) + const blockedLoan = await loan(blockedDeparture, departed) + const shared = await equipment({ total_quantity: 2, total_value: 200, borrowed_value: 200 }) + const sharedLoan = await loan(shared, moved) + const colleagueLoan = await loan(shared, itPerson) + const historical = await equipment({ department_id: otherDepartment.id }) + await loan(historical, itPerson) + const oldOther = await prisma.department.findFirst({ where: { name: '其他', source_system: 'ding_hr_business' } }) + + const env: Record = { + SQLSERVER_HOST: 'mock-only', SQLSERVER_DATABASE: 'mock-only', SQLSERVER_USER: 'mock-only', SQLSERVER_PASSWORD: 'mock-only', + HR_SYNC_MAX_DEACTIVATION_RATIO: '0.5', HR_SYNC_AUTO_RETURN_FROM: new Date(Date.now() - 86400000).toISOString(), + YYD_NOTIFICATION_SEND_ENABLED: 'true', YYD_NOTIFICATION_SEND_FULL_ROLLOUT: 'true' + } + const previousEnv = Object.fromEntries(Object.keys(env).map(key => [key, process.env[key]])) + Object.assign(process.env, env) + const roster = (borrower: Person, dept: string, status = '2') => ({ + sourceUserId: borrower.source_userid, employeeName: borrower.real_name, registerName: borrower.real_name, + registerDept: dept, mainDepts: dept, employeeStatus: status + }) + const originalConnect = sql.connect + ;(sql.connect as any) = async () => ({ request: () => ({ + input() { return this }, + query: async (statement: string) => ({ recordset: statement.includes('hr_employee_dimission') + ? [{ id: 17, sourceUserId: departed.source_userid, lastWorkDay: Date.now() - 3600000, status: 2 }] + : [roster(moved, newDepartment.name), roster(itPerson, oldDepartment.name), roster(departed, oldDepartment.name, '5')] }) + }), close: async () => {} }) + try { + const result = await runHrSync(prefix, 'manual') + const newOther = !oldOther && await prisma.department.findFirst({ where: { name: '其他', source_system: 'ding_hr_business' } }) + if (newOther) departmentIds.push(newOther.id) + assert.equal(result.auto_return?.returned, 1) + assert.equal(result.auto_return?.pending, 1) + assert.deepEqual(result.auto_department, { reassigned: 2, review: 1, retry: 0 }) + for (const before of [root, child]) { + const after = await prisma.equipment.findUniqueOrThrow({ where: { id: before.id } }) + assert.equal(after.department_id, newDepartment.id) + assert.deepEqual({ ...after, department_id: before.department_id, updated_at: before.updated_at }, before) + } + assert.deepEqual(await prisma.borrowRecord.findUniqueOrThrow({ where: { id: movedLoan.id } }), movedLoan) + assert.deepEqual(await prisma.borrowRecord.findUniqueOrThrow({ where: { id: sharedLoan.id } }), sharedLoan) + assert.deepEqual(await prisma.borrowRecord.findUniqueOrThrow({ where: { id: colleagueLoan.id } }), colleagueLoan) + assert.equal((await prisma.equipment.findUniqueOrThrow({ where: { id: shared.id } })).department_id, oldDepartment.id) + assert.equal((await prisma.equipment.findUniqueOrThrow({ where: { id: historical.id } })).department_id, otherDepartment.id) + assert.equal((await prisma.borrowRecord.findUniqueOrThrow({ where: { id: returnedLoan.id } })).status, '已归还') + const returnAsset = await prisma.equipment.findUniqueOrThrow({ where: { id: returned.id } }) + assert.equal(returnAsset.department_id, null); assert.equal(returnAsset.storage_location_id, storage.id) + const movement = await prisma.assetTransferRecord.findFirstOrThrow({ where: { root_equipment_id: root.id }, include: { items: true } }) + assert.equal(movement.operated_by, null); assert.equal(movement.action_type, 'transfer'); assert.equal(movement.items.length, 2) + assert.ok(movement.items.every(item => item.from_department_id === oldDepartment.id && item.to_department_id === newDepartment.id)) + assert.equal(await flowCount(shared.id), 0) + const review = await prisma.operationLog.findFirstOrThrow({ where: { equipment_id: shared.id, operation_type: '人事资产归属待核对' } }) + assert.match(review.operation_details!, /主部门不一致/) + assert.deepEqual(await prisma.borrowRecord.findUniqueOrThrow({ where: { id: blockedLoan.id } }), blockedLoan) + + const pendingCount = () => prisma.operationLog.count({ where: { equipment_id: { in: equipmentIds }, operation_type: '人事资产结果待通知' } }) + assert.equal(await pendingCount(), 4) + assert.equal(await prisma.notification.count({ where: { related_sync_job_id: result.sync_job_id } }), 0) + await prisma.user.updateMany({ where: { id: { in: userIds } }, data: { status: 'active' } }) + const originalTransaction = prisma.$transaction + let consumeAttempted = false + ;(prisma.$transaction as any) = (callback: (tx: Prisma.TransactionClient) => unknown, options: unknown) => + (originalTransaction as any).call(prisma, (tx: Prisma.TransactionClient) => callback(new Proxy(tx, { + get(target, key) { + if (key === '$executeRaw') return async (...args: any[]) => { + const affected = await (Reflect.get(target, key) as Function).apply(target, args) + if (args[0]?.sql?.includes('JSON_SET(operation_details')) { + consumeAttempted = true + throw new Error('fixture interrupted notification transaction') + } + return affected + } + return Reflect.get(target, key) + } + })), options) + try { await assert.rejects(runHrSync(prefix, 'manual'), /fixture interrupted notification transaction/) } + finally { prisma.$transaction = originalTransaction } + assert.equal(consumeAttempted, true) + assert.equal(await pendingCount(), 4) + assert.equal(await prisma.dingTalkDelivery.count({ where: { recipient_user_id: it.id, event_type: 'hr_sync_attention' } }), 0) + assert.equal(await prisma.userNotification.count({ where: { user_id: it.id } }), 0) + + const retried = await runHrSync(prefix, 'manual') + assert.equal(retried.auto_return?.returned, 0) + assert.equal(retried.auto_department, undefined) + assert.equal(await pendingCount(), 0) + const job = await prisma.syncJob.findUniqueOrThrow({ where: { id: retried.sync_job_id }, include: { changes: true } }) + assert.equal(job.changes.length, 4) + const consumed = await prisma.operationLog.findMany({ where: { equipment_id: { in: equipmentIds }, operation_type: '人事资产结果已通知' } }) + assert.equal(consumed.length, 4) + assert.ok(consumed.every(row => JSON.parse(row.operation_details!).sync_job_id === job.id)) + for (const change of job.changes.filter(change => change.change_type.startsWith('person_assets_'))) { + assert.equal(change.processing_status, change.change_type === 'person_assets_review' ? 'pending' : 'processed') + assert.equal(JSON.parse(change.after_snapshot!).assets, undefined, 'outcomes are not quick-return actions') + } + const notice = await prisma.notification.findFirstOrThrow({ where: { related_sync_job_id: job.id }, include: { recipients: true } }) + const deliveries = await prisma.dingTalkDelivery.findMany({ where: { business_type: 'hr_sync', business_id: job.id } }) + assert.deepEqual(notice.recipients.map(row => row.user_id), [it.id]) + assert.deepEqual(deliveries.map(row => row.recipient_user_id), [it.id]) + assert.equal(deliveries[0].status, 'pending') + const ding = (JSON.parse(deliveries[0].payload_json).content as string).replace(/\\-/g, '-') + for (const content of [notice.content!, ding]) { + for (const asset of [root, child, shared, returned, blockedDeparture]) assert.ok(content.includes(asset.equipment_code)) + assert.match(content, /无需扫码/); assert.match(content, /待核对/) + assert.doesNotMatch(content, /快捷入库|请逐项核对/) + } + const repeated = await runHrSync(prefix, 'manual') + assert.equal(repeated.changes_count, 0) + assert.equal(await flowCount(root.id), 1) + assert.equal(await flowCount(returned.id), 1) + assert.equal(await flowCount(blockedDeparture.id), 0) + assert.equal(await prisma.dingTalkDelivery.count({ where: { recipient_user_id: it.id, event_type: 'hr_sync_attention' } }), 1) + assert.equal(await prisma.userNotification.count({ where: { user_id: it.id } }), 1) + } finally { + sql.connect = originalConnect + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + }) + + await t.test('精确借用单范围内同目标部门的多个持有人可同步,旧借用被替换或历史归属不符不覆盖', async () => { + const borrower = await person() + const another = await person(newDepartment.id) + const root = await equipment({ total_quantity: 2 }) + const targetLoan = await loan(root, borrower); await loan(root, another) + const replaced = await equipment() + const oldLoan = await loan(replaced, borrower) + const historical = await equipment({ department_id: otherDepartment.id }) + const historicalLoan = await loan(historical, borrower) + const outsideScope = await equipment() + await loan(outsideScope, borrower) + await stage(borrower, [targetLoan.id, oldLoan.id, historicalLoan.id]) + await prisma.borrowRecord.update({ where: { id: oldLoan.id }, data: { status: '已归还', return_time: new Date() } }) + await loan(replaced, borrower) + const outcomes = await applyHrBorrowDepartmentChanges() + assert.deepEqual(outcomes.map(row => row.kind), ['reassigned', 'review']) + assert.equal((await prisma.equipment.findUniqueOrThrow({ where: { id: root.id } })).department_id, newDepartment.id) + assert.equal((await prisma.equipment.findUniqueOrThrow({ where: { id: replaced.id } })).department_id, oldDepartment.id) + assert.equal((await prisma.equipment.findUniqueOrThrow({ where: { id: historical.id } })).department_id, otherDepartment.id) + assert.equal((await prisma.equipment.findUniqueOrThrow({ where: { id: outsideScope.id } })).department_id, oldDepartment.id) + assert.equal(await flowCount(root.id), 1); assert.equal(await flowCount(replaced.id), 0); assert.equal(await flowCount(historical.id), 0) + assert.equal(await flowCount(outsideScope.id), 0) + }) + + await t.test('流水和审计后保存通知结果失败全部回滚,精确待办留到下次重试', async () => { + const borrower = await person() + const root = await equipment() + await loan(root, borrower); await stage(borrower) + const originalTransaction = prisma.$transaction + ;(prisma.$transaction as any) = (callback: (tx: Prisma.TransactionClient) => unknown, options: unknown) => + (originalTransaction as any).call(prisma, (tx: Prisma.TransactionClient) => callback(new Proxy(tx, { + get(target, key) { + if (key === 'operationLog') return new Proxy(target.operationLog, { + get(log, method) { + if (method === 'create') return async () => { throw new Error('fixture outcome unavailable') } + return Reflect.get(log, method) + } + }) + return Reflect.get(target, key) + } + })), options) + try { assert.deepEqual((await applyHrBorrowDepartmentChanges()).map(row => row.kind), ['retry']) } + finally { prisma.$transaction = originalTransaction } + assert.equal((await prisma.equipment.findUniqueOrThrow({ where: { id: root.id } })).department_id, oldDepartment.id) + assert.equal(await flowCount(root.id), 0) + assert.equal(await prisma.operationLog.count({ where: { equipment_id: root.id, operation_type: '人事资产归属待同步' } }), 1) + assert.deepEqual((await applyHrBorrowDepartmentChanges()).map(row => row.kind), ['reassigned']) + assert.equal(await flowCount(root.id), 1) + assert.deepEqual(await applyHrBorrowDepartmentChanges(), []) + }) + } finally { + try { + const jobs = await prisma.syncJob.findMany({ where: { operator: prefix }, select: { id: true } }) + const jobIds = jobs.map(row => row.id) + await prisma.$transaction(async tx => { + await tx.dingTalkDelivery.deleteMany({ where: { recipient_user_id: { in: userIds } } }) + await tx.notification.deleteMany({ where: { related_sync_job_id: { in: jobIds } } }) + await tx.syncJob.deleteMany({ where: { id: { in: jobIds } } }) + await tx.operationLog.deleteMany({ where: { OR: [{ username: prefix }, { equipment_id: { in: equipmentIds } }] } }) + await tx.assetTransferRecord.deleteMany({ where: { root_equipment_id: { in: equipmentIds } } }) + await tx.borrowRecord.deleteMany({ where: { equipment_id: { in: equipmentIds } } }) + await tx.equipment.updateMany({ where: { id: { in: equipmentIds } }, data: { parent_id: null } }) + await tx.equipment.deleteMany({ where: { id: { in: equipmentIds } } }) + await tx.user.deleteMany({ where: { id: { in: userIds } } }) + await tx.person.deleteMany({ where: { id: { in: personIds } } }) + await tx.storageLocation.deleteMany({ where: { id: { in: storageIds } } }) + await tx.department.deleteMany({ where: { id: { in: departmentIds } } }) + }, { timeout: 20000 }) + } finally { await prisma.$disconnect() } + } +}) diff --git a/backend/src/services/__tests__/hrDepartureReturn.integration.test.ts b/backend/src/services/__tests__/hrDepartureReturn.integration.test.ts index b2f97fd..5c01159 100644 --- a/backend/src/services/__tests__/hrDepartureReturn.integration.test.ts +++ b/backend/src/services/__tests__/hrDepartureReturn.integration.test.ts @@ -77,7 +77,7 @@ test('正式离职自动入库在隔离 MySQL 验证库存、审计、冲突和 const movements = await prisma.assetTransferRecord.findMany({ where: { root_equipment_id: root.id }, include: { items: true } }) assert.equal(movements.length, 1); assert.equal(movements[0].action_type, 'return'); assert.equal(movements[0].operated_by, null) assert.deepEqual(movements[0].items.map(item => item.equipment_id).sort((a, b) => a - b), [root.id, child.id, child2.id]) - const logs = await prisma.operationLog.findMany({ where: { equipment_id: root.id } }) + const logs = await prisma.operationLog.findMany({ where: { equipment_id: root.id, operation_type: '离职资产自动入库' } }) assert.equal(logs.length, 1); assert.equal(logs[0].user_id, null); assert.equal(logs[0].username, 'system-hr-sync') const detail = JSON.parse(logs[0].operation_details!) assert.equal(detail.borrow_record_id, record.id); assert.equal(detail.source_event_id, `${prefix}-event`) diff --git a/backend/src/services/__tests__/hrSyncService.test.ts b/backend/src/services/__tests__/hrSyncService.test.ts index efba76b..101ad78 100644 --- a/backend/src/services/__tests__/hrSyncService.test.ts +++ b/backend/src/services/__tests__/hrSyncService.test.ts @@ -198,7 +198,7 @@ test('花名册钉钉汇总按变动类型分组,并让每件资产独占一 }) assert.match(content, /\*\*同步时间:\*\* 2026年7月16日 10:00/) - assert.match(content, /\*\*执行结果:\*\* 发现 3 项变化,需要 IT 核对/) + assert.match(content, /\*\*执行结果:\*\* 本次同步记录 3 项变化/) assert.match(content, /\*\*变动概览:\*\* 新入职 1 项|岗位变动 1 项|离职 \/ 停用 1 项/) assert.match(content, /\*\*新入职(1)\*\*/) assert.match(content, /部门:产品部/) @@ -214,3 +214,41 @@ test('花名册钉钉汇总按变动类型分组,并让每件资产独占一 '- JS\\-NB\\-3001|笔记本' ]) }) + +test('花名册消息列出实际自动入库、归属更新和待核对编码,普通状态变化不要求扫码', () => { + const outcomes = [ + { kind: 'returned', code: 'AUTO-RETURN' }, + { kind: 'reassigned', code: 'AUTO-TRANSFER' }, + { kind: 'review', code: 'CHECK-ASSET' } + ] as const + const content = buildHrSyncDingTalkContent({ + status: 'success', occurredAt: new Date('2026-11-09T00:00:00Z'), changesCount: 3, summary: '', + changes: outcomes.map(({ kind, code }) => ({ + changeType: `person_assets_${kind}`, entityName: '测试员工', title: '资产同步', detail: '', + afterSnapshot: { assetOutcome: { + personId: 1, sourceUserId: 'mock-only', personName: '测试员工', kind, departmentName: '新部门', + assets: [{ equipmentCode: code, equipmentName: '测试资产' }], reason: '持有人部门不一致' + } } + })) + }).replace(/\\-/g, '-') + assert.match(content, /已自动归还入库 1 项:AUTO-RETURN。保留原库位,无需扫码。/) + assert.match(content, /归属已更新至“新部门”,共 1 项:AUTO-TRANSFER。/) + assert.match(content, /待核对 1 项:CHECK-ASSET。持有人部门不一致。/) + assert.doesNotMatch(content, /请逐项核对|快捷入库|需要 IT 核对/) +}) + +test('批量归属通知为省略提示预留钉钉消息长度', () => { + const changes = Array.from({ length: 105 }, (_, index) => ({ + changeType: 'person_assets_reassigned', entityName: '测试员工', title: '资产归属更新', detail: '', + afterSnapshot: { assetOutcome: { + personId: index + 1, sourceUserId: `employee-${index}`, personName: '测试员工', + kind: 'reassigned', departmentName: '测试部门', + assets: [{ equipmentCode: `JS-TRANS-${String(index).padStart(5, '0')}`, equipmentName: '主机' }] + } } + })) + const content = buildHrSyncDingTalkContent({ + status: 'success', occurredAt: new Date('2026-11-09T00:00:00Z'), changesCount: changes.length, summary: '', changes + }) + assert.ok(content.length <= 4300, `通知长度为 ${content.length}`) + assert.match(content, /另有 \d+ 条变动未在消息中展开/) +}) diff --git a/backend/src/services/hrAssetOutcomeStore.ts b/backend/src/services/hrAssetOutcomeStore.ts new file mode 100644 index 0000000..705a8d4 --- /dev/null +++ b/backend/src/services/hrAssetOutcomeStore.ts @@ -0,0 +1,47 @@ +import { Prisma } from '@prisma/client' +import type { HrAssetSyncOutcome } from './hrSyncHelpers' + +const SYSTEM = 'system-hr-sync' +const PENDING = '人事资产结果待通知' +const NOTIFIED = '人事资产结果已通知' + +type StoredOutcome = { outcome: HrAssetSyncOutcome; reviewKey?: string } + +export const queueHrAssetOutcome = async (tx: Prisma.TransactionClient, outcome: HrAssetSyncOutcome, reviewKey?: string) => { + const asset = outcome.assets[0] + if (reviewKey) { + // The departure transaction holds the asset and borrow locks. The same + // unresolved loan and reason should produce one notification across runs. + const previous = await tx.$queryRaw>(Prisma.sql` + SELECT id FROM operation_log WHERE username = ${SYSTEM} AND equipment_id = ${asset.equipmentId} + AND operation_type IN (${PENDING}, ${NOTIFIED}) + AND JSON_UNQUOTE(JSON_EXTRACT(operation_details, '$.reviewKey')) = ${reviewKey} LIMIT 1 + `) + if (previous.length) return + } + await tx.operationLog.create({ data: { + username: SYSTEM, operation_type: PENDING, equipment_id: asset.equipmentId, + equipment_code: asset.equipmentCode, operation_details: JSON.stringify({ outcome, reviewKey }) + } }) +} + +export const lockPendingHrAssetOutcomes = async (tx: Prisma.TransactionClient) => { + const rows = await tx.$queryRaw>(Prisma.sql` + SELECT id, operation_details FROM operation_log + WHERE operation_type = ${PENDING} AND username = ${SYSTEM} ORDER BY id FOR UPDATE + `) + return rows.map(row => ({ id: row.id, payload: JSON.parse(row.operation_details) as StoredOutcome })) +} + +export const markHrAssetOutcomesNotified = async ( + tx: Prisma.TransactionClient, + rows: Awaited>, + syncJobId: number +) => { + if (!rows.length) return + await tx.$executeRaw(Prisma.sql` + UPDATE operation_log SET operation_type = ${NOTIFIED}, + operation_details = JSON_SET(operation_details, '$.sync_job_id', ${syncJobId}) + WHERE operation_type = ${PENDING} AND id IN (${Prisma.join(rows.map(row => row.id))}) + `) +} diff --git a/backend/src/services/hrBorrowDepartmentService.ts b/backend/src/services/hrBorrowDepartmentService.ts new file mode 100644 index 0000000..fcc470a --- /dev/null +++ b/backend/src/services/hrBorrowDepartmentService.ts @@ -0,0 +1,169 @@ +import { Prisma } from '@prisma/client' +import prisma from '../utils/prisma' +import { HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM, HR_SOURCE_SYSTEM } from '../utils/organization' +import { createAssetMovementRecord, loadAndLockAssetTree } from './assetMovementService' +import { queueHrAssetOutcome } from './hrAssetOutcomeStore' +import type { HrAssetSyncOutcome, OpenBorrowAssetPreview } from './hrSyncHelpers' + +const SYSTEM = 'system-hr-sync' +const PENDING = '人事资产归属待同步' +const TRANSFERRED = '人事资产归属已同步' +const REVIEW = '人事资产归属待核对' +const UNCHANGED = '人事资产归属无需变动' + +type DepartmentChange = { + personId: number + sourceUserId: string + personName: string + fromDepartmentId: number | null + toDepartmentId: number | null + departmentName: string | null + historicalSyncChangeId?: number + backfillBatch?: string +} + +type DepartmentTask = DepartmentChange & { root: OpenBorrowAssetPreview & { equipmentId: number }; borrowRecordIds: number[] } + +// Called while the synced person row is locked, in the same transaction as the +// main-department update. Only loans present during this real change are staged. +export const stageHrBorrowDepartmentChange = async (tx: Prisma.TransactionClient, change: DepartmentChange, borrowRecordIds?: number[]) => { + const loans = await tx.borrowRecord.findMany({ + where: { + person_id: change.personId, status: '已借出', return_time: null, + ...(borrowRecordIds ? { id: { in: borrowRecordIds } } : {}) + }, + select: { id: true, equipment_id: true, equipment_code: true, equipment: { select: { equipment_name: true } } }, + orderBy: { id: 'asc' } + }) + const tasks = new Map() + for (const loan of loans) { + const task = tasks.get(loan.equipment_id) + if (task) task.borrowRecordIds.push(loan.id) + else tasks.set(loan.equipment_id, { + ...change, + root: { equipmentId: loan.equipment_id, equipmentCode: loan.equipment_code, equipmentName: loan.equipment.equipment_name }, + borrowRecordIds: [loan.id] + }) + } + if (tasks.size) await tx.operationLog.createMany({ data: [...tasks.values()].map(task => ({ + username: SYSTEM, + operation_type: PENDING, + equipment_id: task.root.equipmentId, + equipment_code: task.root.equipmentCode, + operation_details: JSON.stringify(task) + })) }) + return tasks.size +} + +const buildOutcome = (task: DepartmentTask, kind: HrAssetSyncOutcome['kind'], assets: OpenBorrowAssetPreview[], reason?: string): HrAssetSyncOutcome => ({ + personId: task.personId, sourceUserId: task.sourceUserId, personName: task.personName, + kind, assets, departmentName: task.departmentName, reason +}) + +const applyDepartmentTask = (logId: number, task: DepartmentTask) => prisma.$transaction(async tx => { + const tree = await loadAndLockAssetTree(tx, task.root.equipmentId) + const root = tree[0] + const [log] = await tx.$queryRaw>( + Prisma.sql`SELECT operation_type FROM operation_log WHERE id = ${logId} FOR UPDATE` + ) + if (log?.operation_type !== PENDING) return null + const finish = (operationType: string, details: Record = {}) => tx.operationLog.update({ + where: { id: logId }, + data: { operation_type: operationType, operation_details: JSON.stringify({ ...task, ...details }) } + }) + const review = async (reason: string) => { + await finish(REVIEW, { reason }) + const outcome = buildOutcome(task, 'review', [task.root], reason) + await queueHrAssetOutcome(tx, outcome) + return outcome + } + const equipmentIds = tree.map(item => item.id) + const loans = await tx.$queryRaw>(Prisma.sql` + SELECT id, equipment_id, person_id, return_time FROM borrow_record + WHERE equipment_id IN (${Prisma.join(equipmentIds)}) AND status = ${'已借出'} ORDER BY id FOR UPDATE + `) + if (!loans.some(loan => task.borrowRecordIds.includes(loan.id) && loan.person_id === task.personId && loan.return_time === null)) { + await finish(UNCHANGED, { reason: '原借用已结束' }) + return null + } + const personIds = [...new Set(loans.map(loan => loan.person_id))].sort((left, right) => left - right) + const people = await tx.$queryRaw>(Prisma.sql` + SELECT id, status, main_department_id, source_userid, source_system FROM person + WHERE id IN (${Prisma.join(personIds)}) ORDER BY id FOR UPDATE + `) + const person = people.find(row => row.id === task.personId) + if (person?.status !== 'active' || person.source_userid !== task.sourceUserId + || person.source_system !== HR_SOURCE_SYSTEM || person.main_department_id !== task.toDepartmentId) { + await finish(UNCHANGED, { reason: '原人员身份或本次主部门已变化' }) + return null + } + if (!task.toDepartmentId) return review('人员没有可用的新主部门') + if (people.length !== personIds.length || people.some(row => row.status !== 'active' || row.main_department_id !== task.toDepartmentId)) { + return review('同一资产仍有其他持有人,主部门不一致') + } + if (root.parent_id !== null || root.total_quantity <= 0 || root.remaining_quantity < 0 + || root.status !== (root.remaining_quantity > 0 ? '可用' : '已借出') + || root.total_quantity - root.remaining_quantity !== loans.length + || loans.some(loan => loan.equipment_id !== root.id || loan.return_time !== null) + || tree.slice(1).some(item => item.status !== '已借出' || item.total_quantity <= 0 || item.remaining_quantity !== 0)) { + return review('资产库存、挂载或借用状态需要核对') + } + if (tree.some(item => item.department_id !== task.fromDepartmentId && item.department_id !== task.toDepartmentId)) { + return review('资产当前归属与人员变动前的部门不一致') + } + const changed = tree.filter(item => item.department_id !== task.toDepartmentId) + if (!changed.length) { + await finish(UNCHANGED, { reason: '资产已归属新主部门' }) + return null + } + const [department] = await tx.$queryRaw>(Prisma.sql` + SELECT status, source_system, name FROM department WHERE id = ${task.toDepartmentId} FOR UPDATE + `) + if (!department || department.status !== 'active' || department.source_system !== HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM) { + return review('新主部门已停用或无法核实') + } + const [maintenance, disposal] = await Promise.all([ + tx.maintenanceRecord.findFirst({ where: { equipment_id: { in: equipmentIds }, status: 'in_progress' }, select: { id: true } }), + tx.disposalRecord.findFirst({ where: { + status: { in: ['pending', 'approved'] }, + OR: [{ equipment_id: { in: equipmentIds } }, { items: { some: { equipment_id: { in: equipmentIds } } } }] + }, select: { id: true } }) + ]) + if (maintenance || disposal) return review('资产树存在未完成的维修或报废') + + const movement = await createAssetMovementRecord(tx, { + items: tree, actionType: 'transfer', toDepartmentId: task.toDepartmentId, + toDepartmentName: department.name, operatedBy: null, reason: '花名册主部门变动,借用资产自动调整归属' + }) + for (const item of changed) { + const updated = await tx.equipment.updateMany({ + where: { id: item.id, parent_id: item.parent_id, department_id: item.department_id, updated_at: item.updated_at }, + data: { department_id: task.toDepartmentId } + }) + if (updated.count !== 1) throw new Error('资产归属已变化,稍后重新核对') + } + const assets = changed.map(item => ({ equipmentId: item.id, equipmentCode: item.equipment_code, equipmentName: item.equipment_name })) + await finish(TRANSFERRED, { movementId: movement.id, assets }) + const outcome = buildOutcome({ ...task, departmentName: department.name }, 'reassigned', assets) + await queueHrAssetOutcome(tx, outcome) + return outcome +}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + +export const applyHrBorrowDepartmentChanges = async (): Promise => { + const logs = await prisma.operationLog.findMany({ + where: { username: SYSTEM, operation_type: PENDING }, orderBy: { id: 'asc' }, + select: { id: true, operation_details: true } + }) + const outcomes: HrAssetSyncOutcome[] = [] + for (const log of logs) { + const task = JSON.parse(log.operation_details!) as DepartmentTask + try { + const outcome = await applyDepartmentTask(log.id, task) + if (outcome) outcomes.push(outcome) + } catch (error) { + console.error('[hr-sync] borrowed asset department change failed for log', log.id, error) + outcomes.push(buildOutcome(task, 'retry', [task.root], '本次未完成,下一次同步将重试')) + } + } + return outcomes +} diff --git a/backend/src/services/hrDepartureReturnService.ts b/backend/src/services/hrDepartureReturnService.ts index f8561d7..7718c2a 100644 --- a/backend/src/services/hrDepartureReturnService.ts +++ b/backend/src/services/hrDepartureReturnService.ts @@ -6,6 +6,8 @@ import { HR_SOURCE_SYSTEM } from '../utils/organization' import { getReturnChildrenDiff } from '../utils/returnSnapshot' import { AssetTreeConflictError, loadAndLockAssetTree } from './assetMovementService' import { completeLockedBorrowReturn } from './borrowReturnService' +import { queueHrAssetOutcome } from './hrAssetOutcomeStore' +import type { HrAssetSyncOutcome } from './hrSyncHelpers' export type HrDepartureSourceRow = { id: unknown @@ -60,7 +62,7 @@ export const selectConfirmedHrDepartures = ( type BorrowCandidate = { id: number; equipment_id: number; person_id: number } -const returnConfirmedDepartureBorrow = async (candidate: BorrowCandidate, departure: HrDeparture) => ( +const returnConfirmedDepartureBorrow = async (candidate: BorrowCandidate, departure: HrDeparture, personName: string) => ( prisma.$transaction(async (tx) => { const items = await loadAndLockAssetTree(tx, candidate.equipment_id) const root = items[0] @@ -83,6 +85,15 @@ const returnConfirmedDepartureBorrow = async (candidate: BorrowCandidate, depart if (!person || person.status !== 'inactive' || person.source_userid !== departure.sourceUserId || person.source_system !== HR_SOURCE_SYSTEM) return false + const who = { personId: record.person_id, sourceUserId: departure.sourceUserId, personName } + const describe = (item: typeof root) => ({ equipmentId: item.id, equipmentCode: item.equipment_code, + equipmentName: item.equipment_name, borrowRecordId: record.id, personId: record.person_id }) + const review = async (reason: string) => { + const outcome: HrAssetSyncOutcome = { ...who, kind: 'review', reason, assets: [describe(root)] } + await queueHrAssetOutcome(tx, outcome, JSON.stringify([departure.sourceEventId, record.id, reason])) + return outcome + } + if (root.parent_id !== null || root.total_quantity <= 0 || root.remaining_quantity < 0 || root.remaining_quantity >= root.total_quantity || root.status !== (root.remaining_quantity > 0 ? '可用' : '已借出') @@ -90,10 +101,13 @@ const returnConfirmedDepartureBorrow = async (candidate: BorrowCandidate, depart || activeBorrows.some(row => row.equipment_id !== root.id || row.return_time !== null || (items.length > 1 && row.person_id !== record.person_id)) || items.slice(1).some(item => item.status !== '已借出' || item.total_quantity <= 0 || item.remaining_quantity !== 0)) { - throw new AssetTreeConflictError('资产树库存、借用状态或持有人需要人工核查') + return review('资产树库存、借用状态或持有人需要人工核查') } - if (getReturnChildrenDiff(items, record)) { - throw new AssetTreeConflictError('当前挂载配件与借出时不一致,需要人工确认') + try { + if (getReturnChildrenDiff(items, record)) return review('当前挂载配件与借出时不一致,需要人工确认') + } catch (error) { + if (error instanceof Error && (error as { status?: number }).status === 409) return review(error.message) + throw error } const [maintenance, disposal] = await Promise.all([ tx.maintenanceRecord.findFirst({ where: { equipment_id: { in: equipmentIds }, status: 'in_progress' }, select: { id: true } }), @@ -102,10 +116,10 @@ const returnConfirmedDepartureBorrow = async (candidate: BorrowCandidate, depart OR: [{ equipment_id: { in: equipmentIds } }, { items: { some: { equipment_id: { in: equipmentIds } } } }] }, select: { id: true } }) ]) - if (maintenance || disposal) throw new AssetTreeConflictError('资产树存在未完成的维修或报废,需要人工核查') + if (maintenance || disposal) return review('资产树存在未完成的维修或报废,需要人工核查') const reason = `正式离职自动归还入仓(借用单 ${record.id})` - const { movementId } = await completeLockedBorrowReturn(tx, { + const { movementId, returnState } = await completeLockedBorrowReturn(tx, { items, borrowRecord: record, returnCondition: '正常', operatedBy: null, reason, requireUnreturned: true }) await tx.operationLog.create({ data: { @@ -117,7 +131,11 @@ const returnConfirmedDepartureBorrow = async (candidate: BorrowCandidate, depart borrow_record_id: record.id, movement_id: movementId, equipment_ids: equipmentIds }) } }) - return true + const outcome: HrAssetSyncOutcome = { + ...who, kind: 'returned', assets: (returnState.borrowedQuantity > 0 ? [root] : items).map(describe) + } + await queueHrAssetOutcome(tx, outcome) + return outcome }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) ) @@ -127,11 +145,12 @@ export type HrDepartureReturnResult = { pending: number failed: number samples: Array<{ borrow_record_id: number; equipment_id: number; reason: string }> + outcomes: HrAssetSyncOutcome[] error?: string } export const returnAssetsForHrDepartures = async (departures: ReadonlyMap): Promise => { - const result: HrDepartureReturnResult = { status: 'completed', returned: 0, pending: 0, failed: 0, samples: [] } + const result: HrDepartureReturnResult = { status: 'completed', returned: 0, pending: 0, failed: 0, samples: [], outcomes: [] } if (!departures.size) return result const candidates = await prisma.borrowRecord.findMany({ where: { @@ -140,21 +159,36 @@ export const returnAssetsForHrDepartures = async (departures: ReadonlyMap= departure.borrowBefore || candidate.created_at >= departure.borrowBefore) continue + const who = { personId: candidate.person_id, sourceUserId: departure.sourceUserId, + personName: candidate.person.nickname || candidate.person.real_name } try { - if (await returnConfirmedDepartureBorrow(candidate, departure)) result.returned++ + const outcome = await returnConfirmedDepartureBorrow(candidate, departure, who.personName) + if (outcome) { + if (outcome.kind === 'returned') result.returned++ + else { + result.pending++ + if (result.samples.length < 20) result.samples.push({ + borrow_record_id: candidate.id, equipment_id: candidate.equipment_id, reason: outcome.reason! + }) + } + result.outcomes.push(outcome) + } } catch (error) { const conflict = error instanceof AssetTreeConflictError || (error as { status?: number })?.status === 409 if (conflict) result.pending++ else result.failed++ - if (result.samples.length < 20) result.samples.push({ - borrow_record_id: candidate.id, equipment_id: candidate.equipment_id, - reason: conflict && error instanceof Error ? error.message : '自动入库事务失败,下一次同步会重试' - }) + const reason = conflict && error instanceof Error ? error.message : '自动入库事务失败,下一次同步会重试' + if (result.samples.length < 20) result.samples.push({ borrow_record_id: candidate.id, equipment_id: candidate.equipment_id, reason }) + result.outcomes.push({ ...who, kind: 'retry', reason, assets: [{ + equipmentId: candidate.equipment_id, borrowRecordId: candidate.id, personId: candidate.person_id, + equipmentCode: candidate.equipment.equipment_code, equipmentName: candidate.equipment.equipment_name + }] }) if (!conflict) console.error('[hr-sync] automatic return failed for borrow', candidate.id, error) } } diff --git a/backend/src/services/hrSyncHelpers.ts b/backend/src/services/hrSyncHelpers.ts index e2acaa7..6ef7634 100644 --- a/backend/src/services/hrSyncHelpers.ts +++ b/backend/src/services/hrSyncHelpers.ts @@ -12,6 +12,16 @@ export type OpenBorrowAssetPreview = { equipmentName: string } +export type HrAssetSyncOutcome = { + personId: number + sourceUserId: string + personName: string + kind: 'returned' | 'reassigned' | 'review' | 'retry' + assets: OpenBorrowAssetPreview[] + departmentName?: string | null + reason?: string +} + export type HrSyncNotificationChange = { entityName: string changeType: string @@ -113,6 +123,11 @@ const extractStructuredBorrowedAssets = (afterSnapshot?: Record | n } const HR_SYNC_NOTIFICATION_GROUPS = [ + { changeType: 'person_assets_returned', label: '资产已自动入库' }, + { changeType: 'person_assets_reassigned', label: '资产归属已更新' }, + { changeType: 'person_assets_review', label: '资产待核对' }, + { changeType: 'person_assets_retry', label: '资产自动处理待重试' }, + { changeType: 'equipment_auto_return_failed', label: '离职自动入库未执行' }, { changeType: 'person_created', label: '新入职' }, { changeType: 'person_position_changed', label: '岗位变动' }, { changeType: 'person_main_department_changed', label: '主部门变动' }, @@ -128,6 +143,16 @@ const HR_SYNC_NOTIFICATION_GROUPS = [ const DINGTALK_CONTENT_LIMIT = 4300 const DINGTALK_ASSET_PREVIEW_LIMIT = 20 +export const buildHrAssetOutcomeDetail = (outcome: HrAssetSyncOutcome, limit = outcome.assets.length) => { + const codes = outcome.assets.slice(0, limit).map(asset => asset.equipmentCode).join('、') + const omitted = outcome.assets.length > limit ? `,其余 ${outcome.assets.length - limit} 项可在系统查看` : '' + const assets = `${outcome.assets.length} 项:${codes}${omitted}` + if (outcome.kind === 'returned') return `已自动归还入库 ${assets}。保留原库位,无需扫码。` + if (outcome.kind === 'reassigned') return `借用资产归属已更新至“${outcome.departmentName || '新主部门'}”,共 ${assets}。借用状态及库位保留,无需扫码。` + const reason = outcome.reason ? `。${outcome.reason}` : '' + return `${outcome.kind === 'review' ? '待核对' : '待自动重试'} ${assets}${reason}。` +} + const getSnapshotText = ( snapshot: Record | null | undefined, key: string, @@ -196,8 +221,7 @@ const buildHrSyncChangeBlock = (change: HrSyncNotificationChange, index: number) return [ heading, `岗位:${escapeNotificationMarkdownInline(getSnapshotText(before, 'position', '未填写'))} → ${escapeNotificationMarkdownInline(getSnapshotText(after, 'position', '未填写'))}`, - '名下资产:', - buildAssetLines(change).join('\n') + ...(extractStructuredBorrowedAssets(after).length > 0 ? ['名下资产:', buildAssetLines(change).join('\n')] : []) ].join('\n\n') case 'person_main_department_changed': return [ @@ -213,9 +237,9 @@ const buildHrSyncChangeBlock = (change: HrSyncNotificationChange, index: number) return [ heading, '状态:离职 / 停用', - '名下资产:', - buildAssetLines(change).join('\n'), - '处理建议:核对未归还资产并完成交接' + ...(extractStructuredBorrowedAssets(after).length > 0 ? [ + '未归还资产待核对:', buildAssetLines(change).join('\n'), '请先核对人员状态及资产交接。' + ] : []) ].join('\n\n') case 'person_reactivated': return [ @@ -236,6 +260,12 @@ const buildHrSyncChangeBlock = (change: HrSyncNotificationChange, index: number) `待处理:${change.countWeight || 1} 件资产无法匹配业务部门`, buildUnresolvedEquipmentLines(change).join('\n') ].join('\n\n') + case 'person_assets_returned': + case 'person_assets_reassigned': + case 'person_assets_review': + case 'person_assets_retry': + return [heading, escapeNotificationMarkdownInline(after?.assetOutcome + ? buildHrAssetOutcomeDetail(after.assetOutcome, DINGTALK_ASSET_PREVIEW_LIMIT) : change.detail)].join('\n\n') default: return [heading, escapeNotificationMarkdownInline(change.detail)].join('\n\n') } @@ -260,6 +290,9 @@ export const buildHrSyncPreviewLine = (change: { detail: string afterSnapshot?: Record | null }) => { + if (change.changeType.startsWith('person_assets_') && change.afterSnapshot?.assetOutcome) { + return `${change.title}(${buildHrAssetOutcomeDetail(change.afterSnapshot.assetOutcome, DINGTALK_ASSET_PREVIEW_LIMIT)})` + } if (change.changeType !== 'person_deactivated' && change.changeType !== 'person_position_changed') { return change.title } @@ -293,7 +326,7 @@ export const buildHrSyncDingTalkContent = (input: { }) => { const resultText = input.status === 'failed' ? '同步失败,需要管理员处理' - : `发现 ${input.changesCount} 项变化,需要 IT 核对` + : `本次同步记录 ${input.changesCount} 项变化` if (input.status === 'failed') { return [ `**同步时间:** ${formatBeijingDateTime(input.occurredAt)}`, @@ -318,6 +351,12 @@ export const buildHrSyncDingTalkContent = (input: { ] const detailBlocks: string[] = [] let shownChanges = 0 + const detailFooter = (shown: number) => { + const omitted = Math.max(0, changes.length - shown) + return omitted > 0 + ? `> 另有 ${omitted} 条变动未在消息中展开,请进入系统查看完整明细。` + : '> 完整明细可在系统内查看。' + } for (const group of groups) { const groupBlocks: string[] = [] @@ -329,7 +368,7 @@ export const buildHrSyncDingTalkContent = (input: { `**${group.label}(${group.changes.reduce((total, item) => total + (item.countWeight || 1), 0)})**`, ...groupBlocks, block, - '> 请逐项核对人员状态及名下资产;完整明细可在系统内查看。' + detailFooter(shownChanges + 1) ].join('\n\n') if (candidate.length > DINGTALK_CONTENT_LIMIT) { break @@ -344,10 +383,5 @@ export const buildHrSyncDingTalkContent = (input: { } } - const omittedChanges = Math.max(0, changes.length - shownChanges) - const footer = omittedChanges > 0 - ? `> 另有 ${omittedChanges} 条变动未在消息中展开,请进入系统查看完整明细。` - : '> 请逐项核对人员状态及名下资产;完整明细可在系统内查看。' - - return [...headerBlocks, ...detailBlocks, footer].join('\n\n') + return [...headerBlocks, ...detailBlocks, detailFooter(shownChanges)].join('\n\n') } diff --git a/backend/src/services/hrSyncService.ts b/backend/src/services/hrSyncService.ts index 34eafa6..85b243a 100644 --- a/backend/src/services/hrSyncService.ts +++ b/backend/src/services/hrSyncService.ts @@ -11,17 +11,21 @@ import { import { buildHrSyncPreviewLine, buildHrSyncDingTalkContent, + buildHrAssetOutcomeDetail, resolveHrSyncPersonDisplayName, resolveHrSyncNotificationRoleRoute, resolveSourcePersonStatus, shouldNotifyHrSync, shouldUseSourceDepartmentBinding, - type OpenBorrowAssetPreview + type OpenBorrowAssetPreview, + type HrAssetSyncOutcome } from './hrSyncHelpers' import { createBusinessNotification, findActiveDingTalkUsersByRoles, - findActiveStationUsersByRoles + findActiveStationUsersByRoles, + type NotificationRecipient, + type StationNotificationRecipient } from './businessNotificationService' import { parseHrAutoReturnFrom, @@ -30,6 +34,8 @@ import { type HrDepartureReturnResult, type HrDepartureSourceRow } from './hrDepartureReturnService' +import { applyHrBorrowDepartmentChanges, stageHrBorrowDepartmentChange } from './hrBorrowDepartmentService' +import { lockPendingHrAssetOutcomes, markHrAssetOutcomesNotified } from './hrAssetOutcomeStore' type SourceDepartment = { sourceGroupId: number | null @@ -103,7 +109,8 @@ type HrSyncResult = { deleted_legacy_departments: number deleted_inactive_business_departments: number } - auto_return?: HrDepartureReturnResult + auto_return?: Omit + auto_department?: { reassigned: number; review: number; retry: number } sync_job_id?: number changes_count?: number } @@ -193,7 +200,12 @@ const CHANGE_TYPE_LABELS: Record = { department_created: '新增部门', department_deactivated: '部门停用', department_reactivated: '部门重新启用', - equipment_department_unresolved: '资产部门待人工处理' + equipment_department_unresolved: '资产部门待人工处理', + person_assets_returned: '资产已自动入库', + person_assets_reassigned: '资产归属已更新', + person_assets_review: '资产待核对', + person_assets_retry: '资产自动处理待重试', + equipment_auto_return_failed: '离职自动入库未执行' } const getConfiguredSet = (value: string, defaultValue: string) => @@ -685,7 +697,7 @@ const getConfirmedHrDepartures = async (pool: any, from: Date, now: Date) => { } const failedAutoReturn = (error: string): HrDepartureReturnResult => ({ - status: 'failed', returned: 0, pending: 0, failed: 0, samples: [], error + status: 'failed', returned: 0, pending: 0, failed: 0, samples: [], outcomes: [], error }) const buildBusinessDepartmentCandidates = (persons: SourcePerson[]): BusinessDepartmentCandidate[] => { @@ -825,6 +837,7 @@ const getPersonSnapshotMap = async () => { const getOpenBorrowAssetMap = async () => { const borrowRecords = await prisma.borrowRecord.findMany({ where: { + status: '已借出', return_time: null }, orderBy: [ @@ -863,6 +876,17 @@ const getOpenBorrowAssetMap = async () => { const buildSyncChange = (change: HrSyncChangeDraft) => change +const buildAssetOutcomeChange = (outcome: HrAssetSyncOutcome): HrSyncChangeDraft => { + const changeType = `person_assets_${outcome.kind}` + return { + entityType: 'person', entityKey: outcome.sourceUserId, entityName: outcome.personName, + changeType, countWeight: outcome.assets.length, + title: `${CHANGE_TYPE_LABELS[changeType]}:${outcome.personName}`, + detail: buildHrAssetOutcomeDetail(outcome), + afterSnapshot: { displayName: outcome.personName, assetOutcome: outcome } + } +} + const diffDepartmentSnapshots = ( beforeMap: Map, afterMap: Map @@ -921,7 +945,8 @@ const diffDepartmentSnapshots = ( const diffPersonSnapshots = ( beforeMap: Map, afterMap: Map, - openBorrowAssetMap: Map + openBorrowAssetMap: Map, + reportedDepartureBorrowIds: ReadonlySet ) => { const changes: HrSyncChangeDraft[] = [] const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) => @@ -957,7 +982,8 @@ const diffPersonSnapshots = ( continue } - const openBorrowAssets = openBorrowAssetMap.get(after.id || before.id) || [] + const openBorrowAssets = (openBorrowAssetMap.get(after.id || before.id) || []) + .filter(asset => !asset.borrowRecordId || !reportedDepartureBorrowIds.has(asset.borrowRecordId)) if (before.status !== after.status) { changes.push( @@ -969,10 +995,10 @@ const diffPersonSnapshots = ( title: `${after.status === 'inactive' ? '人员离职/停用' : '人员重新启用'}:${personDisplayName}`, detail: after.status === 'inactive' - ? `该人员已不在本次同步名单中,请行政确认是否还存在资产交接或借还处理。${ + ? `该人员已不在本次同步名单中。${ openBorrowAssets.length > 0 - ? ` 当前名下还有 ${openBorrowAssets.length} 件待处理资产,可在下方直接快捷入库。` - : ' 当前名下没有待处理资产。' + ? ` 另有 ${openBorrowAssets.length} 笔未归还,需先核对人员状态和资产交接:${openBorrowAssets.map(asset => asset.equipmentCode).join('、')}。` + : '' }` : '该人员重新进入同步名单,可继续参与借还与盘点流程。', beforeSnapshot: before, @@ -1033,16 +1059,11 @@ const diffPersonSnapshots = ( entityName: after.realName, changeType: 'person_position_changed', title: `岗位变动:${personDisplayName}`, - detail: - `岗位已从“${before.position || '未填写'}”调整为“${after.position || '未填写'}”。` + - (openBorrowAssets.length > 0 - ? ` 当前名下还有 ${openBorrowAssets.length} 件资产,可在下方直接快捷入库。` - : ''), + detail: `岗位已从“${before.position || '未填写'}”调整为“${after.position || '未填写'}”。`, beforeSnapshot: before, afterSnapshot: { ...after, - displayName: personDisplayName, - assets: openBorrowAssets + displayName: personDisplayName } }) ) @@ -1075,18 +1096,15 @@ const summarizeHrSyncChanges = (changes: HrSyncChangeDraft[]): HrSyncSummary => .sort((left, right) => right.count - left.count) .map((item) => `${item.label} ${item.count} 项`) - const previewChanges = changes.slice(0, 8).map((change) => ({ + const previewDrafts = changes.slice(0, 8) + const previewChanges = previewDrafts.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 - })}`) + const previewText = previewDrafts + .map((change, index) => `${index + 1}. ${buildHrSyncPreviewLine(change)}`) .join('\n') return { @@ -1105,7 +1123,7 @@ const persistHrSyncArtifacts = async ({ triggerMode, status, result, - changes, + changes: observedChanges, errorMessage }: { operator: string @@ -1115,8 +1133,26 @@ const persistHrSyncArtifacts = async ({ changes: HrSyncChangeDraft[] errorMessage?: string }) => { - const summary = - status === 'failed' + return prisma.$transaction(async (tx) => { + const pending = status === 'success' ? await lockPendingHrAssetOutcomes(tx) : [] + let stationRecipients: StationNotificationRecipient[] = [] + let dingTalkRecipients: NotificationRecipient[] = [] + if (shouldNotifyHrSync(status, observedChanges.length + pending.length)) { + const roleRoute = resolveHrSyncNotificationRoleRoute(status) + stationRecipients = await findActiveStationUsersByRoles(tx, roleRoute.primaryRoles) + if (stationRecipients.length === 0 && roleRoute.fallbackRoles.length > 0) { + stationRecipients = await findActiveStationUsersByRoles(tx, roleRoute.fallbackRoles) + } + dingTalkRecipients = await findActiveDingTalkUsersByRoles(tx, roleRoute.primaryRoles) + if (dingTalkRecipients.length === 0 && roleRoute.fallbackRoles.length > 0) { + dingTalkRecipients = await findActiveDingTalkUsersByRoles(tx, roleRoute.fallbackRoles) + } + } + const hasRecipients = stationRecipients.length > 0 || dingTalkRecipients.length > 0 + const changes = hasRecipients + ? [...pending.map(row => buildAssetOutcomeChange(row.payload.outcome)), ...observedChanges] + : observedChanges + const summary = status === 'failed' ? { title: '组织同步失败', content: `组织同步执行失败,请尽快检查同步配置或源库连接。错误信息:${errorMessage || '未知错误'}`, @@ -1125,7 +1161,6 @@ const persistHrSyncArtifacts = async ({ } : summarizeHrSyncChanges(changes) - return prisma.$transaction(async (tx) => { const syncJob = await tx.syncJob.create({ data: { sync_type: 'hr', @@ -1144,33 +1179,28 @@ const persistHrSyncArtifacts = async ({ 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) - })) + data: changes.map((change) => { + const automaticCompleted = ['person_assets_returned', 'person_assets_reassigned'].includes(change.changeType) + return { + 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), + ...(automaticCompleted ? { + processing_status: 'processed', processing_notes: '系统自动处理完成', processed_at: new Date() + } : {}) + } + }) }) } let notificationId: number | null = null if (shouldNotifyHrSync(status, changes.length)) { - 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', @@ -1202,14 +1232,16 @@ const persistHrSyncArtifacts = async ({ } }) notificationId = notification?.id ?? null + if (hasRecipients) await markHrAssetOutcomesNotified(tx, pending, syncJob.id) } return { syncJobId: syncJob.id, notificationId, - summary + summary, + changesCount: changes.length } - }) + }, { isolationLevel: 'ReadCommitted' }) } const syncDepartments = async (sourceDepartments: BusinessDepartmentCandidate[]) => { @@ -1584,7 +1616,9 @@ const syncPersons = async (sourcePersons: SourcePerson[], localDepartmentMap: Lo source_userid: true, job_number: true, id_number: true, - source_system: true + source_system: true, + status: true, + main_department_id: true } }) @@ -1653,6 +1687,21 @@ const syncPersons = async (sourcePersons: SourcePerson[], localDepartmentMap: Lo await prisma.$transaction(async (tx) => { if (targetPersonId) { + if (matchedBySource?.source_system === HR_SOURCE_SYSTEM && matchedBySource.status === 'active' + && matchedBySource.main_department_id !== payload.main_department_id + && (person.sourceEmployeeStatus === '2' || person.sourceEmployeeStatus === '3')) { + const [before] = await tx.$queryRaw>`SELECT source_userid, source_system, status, main_department_id FROM person WHERE id = ${targetPersonId} FOR UPDATE` + if (before?.status === 'active' && before.source_system === HR_SOURCE_SYSTEM + && before.source_userid === person.sourceUserId && before.main_department_id !== payload.main_department_id) { + await stageHrBorrowDepartmentChange(tx, { + personId: targetPersonId, sourceUserId: person.sourceUserId, personName: resolveHrSyncPersonDisplayName(person), + fromDepartmentId: before.main_department_id, toDepartmentId: payload.main_department_id, + departmentName: departmentBinding.displayDepartment + }) + } + } await tx.person.update({ where: { id: targetPersonId }, data: payload @@ -1690,7 +1739,9 @@ const syncPersons = async (sourcePersons: SourcePerson[], localDepartmentMap: Lo source_userid: person.sourceUserId, job_number: preservedJobNumber, id_number: preservedIdNumber, - source_system: HR_SOURCE_SYSTEM + source_system: HR_SOURCE_SYSTEM, + status: payload.status, + main_department_id: payload.main_department_id }) if (person.idNumber) { @@ -1916,11 +1967,22 @@ export const runHrSync = async ( autoReturn = failedAutoReturn('无法读取离职人员未归还的借用,未执行自动入库') } } + const departmentOutcomes = await applyHrBorrowDepartmentChanges() + const departureOutcomes = autoReturn?.outcomes || [] + const autoReturnSummary = autoReturn && { + status: autoReturn.status, returned: autoReturn.returned, pending: autoReturn.pending, + failed: autoReturn.failed, samples: autoReturn.samples, error: autoReturn.error + } const result: HrSyncResult = { departments: departmentResult, persons: personResult, - ...(autoReturn ? { auto_return: autoReturn } : {}), + ...(autoReturnSummary ? { auto_return: autoReturnSummary } : {}), + ...(departmentOutcomes.length ? { auto_department: { + reassigned: departmentOutcomes.filter(outcome => outcome.kind === 'reassigned').reduce((n, outcome) => n + outcome.assets.length, 0), + review: departmentOutcomes.filter(outcome => outcome.kind === 'review').length, + retry: departmentOutcomes.filter(outcome => outcome.kind === 'retry').length + } } : {}), equipment_department_migration: equipmentDepartmentMigration, cleanup: { deleted_inactive_persons: deletedInactivePersons, @@ -1936,10 +1998,17 @@ export const runHrSync = async ( getOpenBorrowAssetMap() ]) + const reportedDepartureBorrowIds = new Set(departureOutcomes.flatMap(outcome => + outcome.assets.flatMap(asset => asset.borrowRecordId ? [asset.borrowRecordId] : []))) const changes: HrSyncChangeDraft[] = [ + ...[...departureOutcomes, ...departmentOutcomes].filter(outcome => outcome.kind === 'retry').map(buildAssetOutcomeChange), ...diffDepartmentSnapshots(beforeDepartmentMap, afterDepartmentMap), - ...diffPersonSnapshots(beforePersonMap, afterPersonMap, openBorrowAssetMap) + ...diffPersonSnapshots(beforePersonMap, afterPersonMap, openBorrowAssetMap, reportedDepartureBorrowIds) ] + if (autoReturn?.error) changes.unshift(buildSyncChange({ + entityType: 'equipment', entityKey: 'departure_auto_return', entityName: '离职自动入库', + changeType: 'equipment_auto_return_failed', title: '离职自动入库未执行', detail: autoReturn.error + })) if (equipmentDepartmentMigration.unresolved > 0) { const sampleText = equipmentDepartmentMigration.samples @@ -1978,7 +2047,7 @@ export const runHrSync = async ( const finalResult: HrSyncResult = { ...result, sync_job_id: artifact.syncJobId, - changes_count: changes.length + changes_count: artifact.changesCount } await writeSyncLog(finalResult, operator, true) diff --git a/frontend/scripts/test-notification-quick-return.ts b/frontend/scripts/test-notification-quick-return.ts deleted file mode 100644 index 427a1ee..0000000 --- a/frontend/scripts/test-notification-quick-return.ts +++ /dev/null @@ -1,76 +0,0 @@ -import assert from 'node:assert/strict' -import type { BorrowRecord, HrSyncBorrowedAssetPreview } from '../src/types.ts' -import { - buildNotificationQuickReturnAssetKey, - findNotificationQuickReturnRecord -} from '../src/views/notifications/notificationQuickReturn.ts' - -const assetWithBorrowRecord: HrSyncBorrowedAssetPreview = { - equipmentCode: 'JS-PC-3001', - equipmentName: '主机', - borrowRecordId: 901 -} - -const assetWithoutBorrowRecord: HrSyncBorrowedAssetPreview = { - equipmentCode: 'JS-MON-2001', - equipmentName: '显示器' -} - -const borrowRecords: BorrowRecord[] = [ - { - id: 901, - equipment_id: 3001, - equipment_code: 'JS-PC-3001', - person_id: 11, - person_name: '张亮', - person_nickname: '三藏', - borrow_time: '2026-03-26T09:00:00.000Z', - status: '已借出', - created_at: '2026-03-26T09:00:00.000Z', - updated_at: '2026-03-26T09:00:00.000Z' - }, - { - id: 902, - equipment_id: 3001, - equipment_code: 'JS-PC-3001', - person_id: 12, - person_name: '李想', - person_nickname: '小李', - borrow_time: '2026-03-26T10:00:00.000Z', - status: '已借出', - created_at: '2026-03-26T10:00:00.000Z', - updated_at: '2026-03-26T10:00:00.000Z' - } -] - -assert.equal( - buildNotificationQuickReturnAssetKey(assetWithBorrowRecord), - 'borrow-record:901', - '带借用记录编号的资产应优先用借用记录生成稳定 key' -) - -assert.equal( - buildNotificationQuickReturnAssetKey(assetWithoutBorrowRecord), - 'equipment:JS-MON-2001', - '没有借用记录编号时应回退到资产编码' -) - -assert.equal( - findNotificationQuickReturnRecord(borrowRecords, assetWithBorrowRecord)?.id, - 901, - '带借用记录编号时应优先命中对应借用记录' -) - -assert.equal( - findNotificationQuickReturnRecord(borrowRecords, assetWithoutBorrowRecord), - null, - '存在多条借用记录且没有指定借用记录编号时,不应擅自自动选中' -) - -assert.equal( - findNotificationQuickReturnRecord([borrowRecords[0]], assetWithoutBorrowRecord)?.id, - 901, - '只有一条借用记录时,应允许自动选中' -) - -console.log('notification quick return tests: ok') diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d79cb5c..c452b7f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -780,6 +780,7 @@ export interface HrSyncBorrowedAssetPreview { export interface HrSyncPersonAfterSnapshot { displayName?: string | null assets?: HrSyncBorrowedAssetPreview[] + assetOutcome?: { assets: HrSyncBorrowedAssetPreview[] } [key: string]: any } diff --git a/frontend/src/views/notifications/NotificationCenter.vue b/frontend/src/views/notifications/NotificationCenter.vue index b452078..0511679 100644 --- a/frontend/src/views/notifications/NotificationCenter.vue +++ b/frontend/src/views/notifications/NotificationCenter.vue @@ -181,7 +181,7 @@

同步详情

- 这里只看这一条同步消息对应的完整变更明细,不再跳到设置页。 + 查看本次花名册同步的变更和资产处理结果。

- - - - - {{ CHANGE_TYPE_LABELS[change.change_type] || change.change_type }} - - - -
- - {{ getChangeProcessingLabel(change.processing_status) }} - - 处理人:{{ change.processed_by_name || change.processed_by || '未处理' }} - 处理时间:{{ change.processed_at ? formatDateTime(change.processed_at) : '未处理' }} + {{ asset.equipmentCode }} + {{ asset.equipmentName }} + +
+ + {{ CHANGE_TYPE_LABELS[change.change_type] || change.change_type }} + -
- 本次同步没有变化明细。 + +
+ + {{ getChangeProcessingLabel(change.processing_status) }} + + 处理人:{{ getChangeProcessorLabel(change) }} + 处理时间:{{ change.processed_at ? formatDateTime(change.processed_at) : '未处理' }}
- - +
+ 本次同步没有变化明细。 +
@@ -465,41 +311,23 @@