feat: sync borrowed asset departments and notify HR outcomes

This commit is contained in:
WuYongJie 2026-11-09 13:34:52 +08:00
parent 02961ebf0e
commit ad0e130149
12 changed files with 813 additions and 645 deletions

View File

@ -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<Prisma.EquipmentUncheckedCreateInput> = {}) => {
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<string, string> = {
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() }
}
})

View File

@ -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`)

View File

@ -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+ 条变动未在消息中展开/)
})

View File

@ -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<Array<{ id: number }>>(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<Array<{ id: number; operation_details: string }>>(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<ReturnType<typeof lockPendingHrAssetOutcomes>>,
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))})
`)
}

View File

@ -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<number, DepartmentTask>()
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<Array<{ operation_type: string }>>(
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<string, unknown> = {}) => 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<Array<{ id: number; equipment_id: number; person_id: number; return_time: Date | null }>>(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<Array<{ id: number; status: string; main_department_id: number | null; source_userid: string | null; source_system: string | null }>>(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<Array<{ status: string; source_system: string | null; name: string }>>(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<HrAssetSyncOutcome[]> => {
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
}

View File

@ -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<string, HrDeparture>): Promise<HrDepartureReturnResult> => {
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<string
},
orderBy: [{ equipment_id: 'asc' }, { id: 'asc' }],
select: { id: true, equipment_id: true, person_id: true, borrow_time: true, created_at: true,
person: { select: { source_userid: true } } }
equipment: { select: { equipment_code: true, equipment_name: true } },
person: { select: { source_userid: true, nickname: true, real_name: true } } }
})
for (const candidate of candidates) {
const departure = departures.get(candidate.person.source_userid || '')
if (!departure || candidate.borrow_time >= 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)
}
}

View File

@ -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<string, any> | 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<string, any> | 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<string, any> | 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')
}

View File

@ -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<HrDepartureReturnResult, 'outcomes'>
auto_department?: { reassigned: number; review: number; retry: number }
sync_job_id?: number
changes_count?: number
}
@ -193,7 +200,12 @@ const CHANGE_TYPE_LABELS: Record<string, string> = {
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<string, DepartmentSnapshot>,
afterMap: Map<string, DepartmentSnapshot>
@ -921,7 +945,8 @@ const diffDepartmentSnapshots = (
const diffPersonSnapshots = (
beforeMap: Map<string, PersonSnapshot>,
afterMap: Map<string, PersonSnapshot>,
openBorrowAssetMap: Map<number, OpenBorrowAssetPreview[]>
openBorrowAssetMap: Map<number, OpenBorrowAssetPreview[]>,
reportedDepartureBorrowIds: ReadonlySet<number>
) => {
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<Array<{
source_userid: string | null; source_system: string | null; status: string; main_department_id: number | null
}>>`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)

View File

@ -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')

View File

@ -780,6 +780,7 @@ export interface HrSyncBorrowedAssetPreview {
export interface HrSyncPersonAfterSnapshot {
displayName?: string | null
assets?: HrSyncBorrowedAssetPreview[]
assetOutcome?: { assets: HrSyncBorrowedAssetPreview[] }
[key: string]: any
}

View File

@ -181,7 +181,7 @@
<div>
<h3 class="form-panel__title">同步详情</h3>
<p class="form-panel__desc">
这里只看这一条同步消息对应的完整变更明细,不再跳到设置页。
查看本次花名册同步的变更和资产处理结果。
</p>
</div>
<button type="button" class="btn-secondary text-sm" @click="closeSyncDetail">
@ -244,215 +244,61 @@
</span>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_380px]">
<div class="order-2 xl:order-1">
<div class="panel">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium text-text-primary">完整变更</p>
<span class="badge badge-neutral">{{ selectedSyncJob.changes.length }} 条</span>
</div>
<div class="panel">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium text-text-primary">完整变更</p>
<span class="badge badge-neutral">{{ selectedSyncJob.changes.length }} 条</span>
</div>
<div v-if="selectedSyncJob.changes.length > 0" class="mt-4 space-y-3">
<div
v-for="change in selectedSyncJob.changes"
:key="change.id"
class="rounded-lg border border-border bg-bg-secondary px-4 py-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-text-primary">{{ getChangeDisplayTitle(change) }}</p>
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-text-secondary">
{{ change.detail || '无详细说明' }}
</p>
<div v-if="selectedSyncJob.changes.length > 0" class="mt-4 space-y-3">
<div
v-for="change in selectedSyncJob.changes"
:key="change.id"
class="rounded-lg border border-border bg-bg-secondary px-4 py-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-text-primary">{{ getChangeDisplayTitle(change) }}</p>
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-text-secondary">
{{ change.detail || '无详细说明' }}
</p>
<div
v-if="canBorrow && getChangeAssets(change).length > 0"
class="mt-4 rounded-xl border border-border bg-bg-primary/40 p-3"
<div
v-if="getChangeAssets(change).length"
class="mt-4 rounded-xl border border-border bg-bg-primary/40 p-3"
>
<p class="text-xs font-medium text-text-tertiary">
{{ change.after_snapshot?.assetOutcome ? '涉及资产' : '同步时记录的资产' }}
</p>
<ul class="mt-3 flex flex-wrap gap-2">
<li
v-for="(asset, index) in getChangeAssets(change)"
:key="index"
class="flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-border bg-bg-elevated px-3 py-2 text-xs [overflow-wrap:anywhere]"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xs uppercase tracking-wider text-text-tertiary">待处理资产</p>
<span class="text-xs text-text-tertiary">点资产即可在当前弹窗内直接入库</span>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
v-for="asset in getChangeAssets(change)"
:key="`${change.id}-${buildQuickReturnAssetKey(asset)}`"
type="button"
class="inline-flex min-h-[44px] items-center gap-2 rounded-full border px-3 py-2 text-left transition"
:class="isQuickReturnAssetProcessed(asset)
? 'cursor-not-allowed border-status-success/30 bg-status-success/10 text-status-success'
: 'border-border bg-bg-elevated hover:border-accent-primary/40 hover:bg-bg-hover'"
:disabled="isQuickReturnAssetProcessed(asset)"
@click="openQuickReturn(asset)"
>
<span class="text-xs font-semibold" :class="isQuickReturnAssetProcessed(asset) ? 'text-status-success' : 'text-text-primary'">
{{ asset.equipmentCode }}
</span>
<span class="text-xs" :class="isQuickReturnAssetProcessed(asset) ? 'text-status-success/90' : 'text-text-secondary'">
{{ asset.equipmentName }}
</span>
<span class="badge" :class="isQuickReturnAssetProcessed(asset) ? 'badge-success' : 'badge-neutral'">
{{ isQuickReturnAssetProcessed(asset) ? '已入库' : '快捷入库' }}
</span>
</button>
</div>
</div>
</div>
<span class="badge badge-neutral">
{{ CHANGE_TYPE_LABELS[change.change_type] || change.change_type }}
</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-text-tertiary">
<span class="badge" :class="getChangeProcessingBadgeClass(change.processing_status)">
{{ getChangeProcessingLabel(change.processing_status) }}
</span>
<span>处理人:{{ change.processed_by_name || change.processed_by || '未处理' }}</span>
<span>处理时间:{{ change.processed_at ? formatDateTime(change.processed_at) : '未处理' }}</span>
<span class="font-semibold text-text-primary">{{ asset.equipmentCode }}</span>
<span class="text-text-secondary">{{ asset.equipmentName }}</span>
</li>
</ul>
</div>
</div>
<span class="badge badge-neutral">
{{ CHANGE_TYPE_LABELS[change.change_type] || change.change_type }}
</span>
</div>
<div v-else class="empty-state mt-4">
本次同步没有变化明细。
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-text-tertiary">
<span class="badge" :class="getChangeProcessingBadgeClass(change.processing_status)">
{{ getChangeProcessingLabel(change.processing_status) }}
</span>
<span>处理人:{{ getChangeProcessorLabel(change) }}</span>
<span>处理时间:{{ change.processed_at ? formatDateTime(change.processed_at) : '未处理' }}</span>
</div>
</div>
</div>
<aside v-if="canBorrow" class="order-1 xl:order-2">
<div class="panel space-y-4 xl:sticky xl:top-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">快捷入库</p>
<p class="mt-1 text-xs leading-5 text-text-tertiary">
在当前消息里直接处理,不会跳走,处理完可以继续点下一件。
</p>
</div>
<button
v-if="quickReturnAsset"
type="button"
class="btn-secondary text-sm"
@click="closeQuickReturnPanel"
>
收起
</button>
</div>
<div
v-if="quickReturnNotice"
class="rounded-lg border px-4 py-3 text-sm"
:class="getQuickReturnNoticeClass(quickReturnNotice.type)"
>
{{ quickReturnNotice.message }}
</div>
<div v-if="quickReturnLoading" class="rounded-lg border border-border bg-bg-primary/40 px-4 py-6 text-sm text-text-tertiary">
正在读取这件资产的借用记录...
</div>
<div v-else-if="quickReturnBorrowInfo && quickReturnAsset" class="space-y-4">
<div class="rounded-lg border border-border bg-bg-primary/40 p-4">
<p class="text-xs uppercase tracking-wider text-text-tertiary">当前资产</p>
<p class="mt-2 text-base font-semibold text-text-primary">{{ quickReturnBorrowInfo.equipment_name }}</p>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-text-secondary">
<span class="badge badge-neutral">{{ quickReturnBorrowInfo.equipment_code }}</span>
<span v-if="quickReturnBorrowInfo.storageLocation?.name">地址:{{ quickReturnBorrowInfo.storageLocation.name }}</span>
<span>未归还记录 {{ quickReturnBorrowInfo.borrow_records.length }} 条</span>
</div>
</div>
<div class="field-stack">
<label class="field-label">归还使用者</label>
<input
v-model.trim="quickReturnRecordKeyword"
type="text"
class="input"
placeholder="输入花名 / 姓名 / 工号 / 部门搜索"
@keyup.enter="handleQuickReturnRecordKeywordSubmit"
/>
<p class="text-xs text-text-tertiary">
{{ quickReturnBorrowInfo.borrow_records.length > 1
? '这件资产有多条未归还记录,请点选正确使用者。'
: '当前只有 1 条未归还记录,系统已自动帮你选中。' }}
</p>
<div v-if="showQuickReturnRecordResults" class="space-y-2">
<button
v-for="record in quickReturnFilteredRecords"
:key="record.id"
type="button"
class="touch-option w-full text-left"
:class="{ 'is-active': quickReturnSelectedBorrowRecordId === record.id }"
@click="selectQuickReturnBorrowRecord(record)"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">
{{ record.person_nickname }} · {{ record.person_name }}
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ record.person?.department || '未填写部门' }}
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
</p>
</div>
<span v-if="quickReturnSelectedBorrowRecordId === record.id" class="badge badge-success">已选中</span>
</div>
</button>
<p v-if="quickReturnFilteredRecords.length === 0" class="py-3 text-center text-sm text-text-tertiary">
没有匹配到使用者,请换个关键词再试。
</p>
</div>
</div>
<div class="field-stack">
<label class="field-label">归还状态</label>
<select v-model="quickReturnForm.return_condition" class="select">
<option value="正常">正常</option>
<option value="损坏">损坏</option>
<option value="丢失">丢失</option>
</select>
</div>
<div class="panel text-sm text-text-secondary">
全部归还后自动清空部门;多数量资产仍有在借数量时保留当前部门。
</div>
<div class="field-stack">
<label class="field-label">备注</label>
<textarea
v-model="quickReturnForm.notes"
class="input"
rows="3"
placeholder="可选,补充这次归还情况"
></textarea>
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<button
type="button"
class="btn-primary flex-1 text-sm"
:disabled="!canSubmitQuickReturn"
@click="submitQuickReturn"
>
{{ quickReturnSubmitting ? '处理中...' : '确认入库' }}
</button>
<button
type="button"
class="btn-secondary text-sm"
:disabled="quickReturnSubmitting"
@click="closeQuickReturnPanel"
>
暂不处理
</button>
</div>
</div>
<div v-else class="rounded-lg border border-dashed border-border bg-bg-primary/20 px-4 py-6 text-sm leading-6 text-text-tertiary">
点左侧的资产胶囊后,这里会直接出现该资产的快捷入库面板。处理完当前这件后,你可以继续点下一件,不需要离开系统记录。
</div>
</div>
</aside>
<div v-else class="empty-state mt-4">
本次同步没有变化明细。
</div>
</div>
</div>
@ -465,41 +311,23 @@
</template>
<script setup lang="ts">
import { getReturnCompletionText, submitReturnWithConfirmation } from '@/utils/returnConfirmation'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { formatDateTime } from '@/utils/format'
import type {
BorrowRecord,
HrSyncBorrowedAssetPreview,
HrSyncChangeItem,
HrSyncChangeProcessingStatus,
HrSyncJobDetail,
HrSyncPersonAfterSnapshot,
NotificationItem,
StorageLocation
NotificationItem
} from '@/types'
import { getBorrowRecordByCode, scanReturn } from '@/api/borrow'
import {
getNotificationList,
markAllNotificationsAsRead,
markNotificationAsRead
} from '@/api/notifications'
import { getHrSyncJobDetail } from '@/api/sync'
import {
buildManualReturnRecordDisplay,
filterManualReturnRecordCandidates,
findPreferredManualReturnRecordCandidate,
isManualReturnSelectedRecordKeyword,
shouldShowManualReturnRecordResults
} from '@/views/borrow/manualReturnRecordSearch'
import { canSubmitReturnScan } from '@/views/borrow/returnScanState'
import { useNotificationStore } from '@/stores/notifications'
import { useAuthStore } from '@/stores/auth'
import { PERMISSIONS } from '@/auth/permissions'
import {
buildNotificationQuickReturnAssetKey,
findNotificationQuickReturnRecord
} from './notificationQuickReturn'
import {
getNotificationActionLabel,
getNotificationRecordBadge,
@ -508,8 +336,6 @@ import {
import MobileActionSheet from '@/components/MobileActionSheet.vue'
const notificationStore = useNotificationStore()
const authStore = useAuthStore()
const canBorrow = computed(() => authStore.can(PERMISSIONS.ASSETS_BORROW))
const notifications = ref<NotificationItem[]>([])
const total = ref(0)
@ -522,28 +348,6 @@ const mobileActionNotification = ref<NotificationItem | null>(null)
const syncDetailOpen = ref(false)
const syncDetailLoading = ref(false)
const selectedSyncJob = ref<HrSyncJobDetail | null>(null)
const quickReturnAsset = ref<HrSyncBorrowedAssetPreview | null>(null)
const quickReturnLoading = ref(false)
const quickReturnSubmitting = ref(false)
const quickReturnBorrowInfo = ref<{
equipment_code: string
equipment_name: string
unit_price: number
equipment_value: number
storage_location_id?: number | null
storageLocation?: StorageLocation | null
borrow_records: BorrowRecord[]
can_return: boolean
message: string
} | null>(null)
const quickReturnRecordKeyword = ref('')
const quickReturnSelectedBorrowRecordId = ref<number | null>(null)
const quickReturnForm = ref({
return_condition: '正常',
notes: ''
})
const quickReturnNotice = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null)
const processedQuickReturnAssetKeys = ref<string[]>([])
const CHANGE_TYPE_LABELS: Record<string, string> = {
person_created: '新人入职',
@ -557,7 +361,12 @@ const CHANGE_TYPE_LABELS: Record<string, string> = {
department_hierarchy_changed: '部门层级变动',
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 CHANGE_PROCESSING_LABELS: Record<HrSyncChangeProcessingStatus, string> = {
@ -573,33 +382,6 @@ const CHANGE_PROCESSING_BADGES: Record<HrSyncChangeProcessingStatus, string> = {
}
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const quickReturnSelectedRecord = computed(() => {
if (!quickReturnBorrowInfo.value || quickReturnSelectedBorrowRecordId.value === null) {
return null
}
return (
quickReturnBorrowInfo.value.borrow_records.find(
(record) => record.id === quickReturnSelectedBorrowRecordId.value
) || null
)
})
const quickReturnFilteredRecords = computed(() =>
filterManualReturnRecordCandidates(quickReturnBorrowInfo.value?.borrow_records || [], quickReturnRecordKeyword.value)
)
const showQuickReturnRecordResults = computed(() =>
shouldShowManualReturnRecordResults(quickReturnSelectedRecord.value, quickReturnRecordKeyword.value)
)
const canSubmitQuickReturn = computed(() =>
canBorrow.value && canSubmitReturnScan({
hasBorrowRecordInfo: Boolean(quickReturnBorrowInfo.value),
selectedBorrowRecordId: quickReturnSelectedBorrowRecordId.value,
loading: quickReturnSubmitting.value
})
)
const normalizeChangeProcessingStatus = (
status?: HrSyncChangeProcessingStatus | string | null
@ -620,24 +402,14 @@ const getChangeProcessingLabel = (status?: HrSyncChangeProcessingStatus | string
const getChangeProcessingBadgeClass = (status?: HrSyncChangeProcessingStatus | string | null) =>
CHANGE_PROCESSING_BADGES[normalizeChangeProcessingStatus(status)]
const getQuickReturnNoticeClass = (type: 'success' | 'warning' | 'error') => {
if (type === 'success') {
return 'border-status-success/30 bg-status-success/10 text-status-success'
}
if (type === 'warning') {
return 'border-status-warning/30 bg-status-warning/10 text-status-warning'
}
return 'border-status-error/30 bg-status-error/10 text-status-error'
const getChangeProcessorLabel = (change: HrSyncChangeItem) => {
const processedBy = change.processed_by_name || change.processed_by
if (processedBy) return processedBy
if (change.processing_status === 'processed' &&
(change.change_type === 'person_assets_returned' || change.change_type === 'person_assets_reassigned')) return '系统'
return '未处理'
}
const buildQuickReturnAssetKey = (asset: HrSyncBorrowedAssetPreview) =>
buildNotificationQuickReturnAssetKey(asset)
const isQuickReturnAssetProcessed = (asset: HrSyncBorrowedAssetPreview) =>
processedQuickReturnAssetKeys.value.includes(buildQuickReturnAssetKey(asset))
const getChangeDisplayTitle = (change: HrSyncChangeItem) => {
const personChangeTitleMap: Record<string, string> = {
person_created: '新人入职',
@ -701,8 +473,9 @@ const extractDetailAssets = (detail?: string | null): HrSyncBorrowedAssetPreview
const getChangeAssets = (change: HrSyncChangeItem): HrSyncBorrowedAssetPreview[] => {
const afterSnapshot = change.after_snapshot as HrSyncPersonAfterSnapshot | null
if (afterSnapshot && Array.isArray(afterSnapshot.assets) && afterSnapshot.assets.length > 0) {
return afterSnapshot.assets.filter(
const assets = afterSnapshot?.assetOutcome?.assets ?? afterSnapshot?.assets
if (Array.isArray(assets) && assets.length > 0) {
return assets.filter(
(asset) => Boolean(asset?.equipmentCode) && Boolean(asset?.equipmentName)
)
}
@ -785,167 +558,11 @@ const handleMobileMarkRead = async () => {
const closeSyncDetail = () => {
syncDetailOpen.value = false
selectedSyncJob.value = null
quickReturnNotice.value = null
processedQuickReturnAssetKeys.value = []
closeQuickReturnPanel()
}
const resetQuickReturnForm = () => {
quickReturnRecordKeyword.value = ''
quickReturnSelectedBorrowRecordId.value = null
quickReturnForm.value = {
return_condition: '正常',
notes: ''
}
}
const closeQuickReturnPanel = () => {
quickReturnAsset.value = null
quickReturnBorrowInfo.value = null
quickReturnLoading.value = false
quickReturnSubmitting.value = false
resetQuickReturnForm()
}
const selectQuickReturnBorrowRecord = (record: BorrowRecord) => {
quickReturnSelectedBorrowRecordId.value = record.id
quickReturnRecordKeyword.value = buildManualReturnRecordDisplay(record)
}
const markQuickReturnAssetProcessed = (asset: HrSyncBorrowedAssetPreview) => {
const assetKey = buildQuickReturnAssetKey(asset)
if (!processedQuickReturnAssetKeys.value.includes(assetKey)) {
processedQuickReturnAssetKeys.value = [...processedQuickReturnAssetKeys.value, assetKey]
}
}
const handleQuickReturnRecordKeywordSubmit = () => {
const preferredRecord = findPreferredManualReturnRecordCandidate(
quickReturnFilteredRecords.value,
quickReturnRecordKeyword.value
)
if (preferredRecord) {
selectQuickReturnBorrowRecord(preferredRecord)
return
}
if (quickReturnFilteredRecords.value.length > 1) {
quickReturnNotice.value = {
type: 'warning',
message: '匹配到多条借用记录,请先点选正确的使用者。'
}
return
}
if (quickReturnRecordKeyword.value.trim()) {
quickReturnNotice.value = {
type: 'warning',
message: '没有匹配到使用者,请换个关键词再试。'
}
}
}
const openQuickReturn = async (asset: HrSyncBorrowedAssetPreview) => {
if (!canBorrow.value) return
if (isQuickReturnAssetProcessed(asset)) {
return
}
quickReturnAsset.value = asset
quickReturnBorrowInfo.value = null
quickReturnNotice.value = null
resetQuickReturnForm()
quickReturnLoading.value = true
try {
const response = await getBorrowRecordByCode(asset.equipmentCode)
const borrowInfo = response.data || null
quickReturnBorrowInfo.value = borrowInfo
if (!borrowInfo) {
quickReturnNotice.value = {
type: 'error',
message: '没有读取到这件资产的借用记录。'
}
return
}
const preferredRecord = findNotificationQuickReturnRecord(borrowInfo.borrow_records, asset)
if (preferredRecord) {
selectQuickReturnBorrowRecord(preferredRecord)
quickReturnNotice.value = {
type: 'success',
message: `已定位到 ${borrowInfo.equipment_name} 的归还记录,可以直接确认入库。`
}
return
}
quickReturnNotice.value = {
type: 'warning',
message: `已读取 ${borrowInfo.equipment_name},请先选择正确的归还使用者。`
}
} catch (error: any) {
quickReturnBorrowInfo.value = null
quickReturnNotice.value = {
type: 'error',
message: error?.message || '读取借用记录失败,请稍后再试。'
}
} finally {
quickReturnLoading.value = false
}
}
const submitQuickReturn = async () => {
if (!canBorrow.value) return
if (!quickReturnBorrowInfo.value || !quickReturnAsset.value) {
return
}
const record = quickReturnSelectedRecord.value
if (!record) {
quickReturnNotice.value = {
type: 'warning',
message: '请先选中正确的归还使用者。'
}
return
}
const payload = {
equipment_code: quickReturnBorrowInfo.value.equipment_code,
return_condition: quickReturnForm.value.return_condition,
notes: quickReturnForm.value.notes || undefined,
person_id: record.person_id,
borrow_record_id: record.id
}
quickReturnSubmitting.value = true
try {
const result = await submitReturnWithConfirmation(payload, scanReturn, (message) => window.confirm(message))
if (!result) return
markQuickReturnAssetProcessed(quickReturnAsset.value)
quickReturnNotice.value = {
type: 'success',
message: `${quickReturnBorrowInfo.value.equipment_name} ${getReturnCompletionText(payload.return_condition)}(使用者:${record.person_nickname})。你现在可以继续处理下一件。`
}
closeQuickReturnPanel()
} catch (error: any) {
quickReturnNotice.value = {
type: 'error',
message: error?.message || '快捷入库失败,请稍后再试。'
}
} finally {
quickReturnSubmitting.value = false
}
}
const openSyncDetail = async (syncJobId: number) => {
syncDetailOpen.value = true
syncDetailLoading.value = true
quickReturnNotice.value = null
processedQuickReturnAssetKeys.value = []
closeQuickReturnPanel()
try {
const response = await getHrSyncJobDetail(syncJobId)
@ -983,20 +600,6 @@ const handleNextPage = () => {
fetchNotifications()
}
watch(
() => quickReturnRecordKeyword.value,
(value) => {
const matchedSelectedRecord = quickReturnSelectedRecord.value
const shouldKeepCurrentSelection =
matchedSelectedRecord !== null &&
isManualReturnSelectedRecordKeyword(matchedSelectedRecord, value)
if (quickReturnSelectedBorrowRecordId.value !== null && !shouldKeepCurrentSelection) {
quickReturnSelectedBorrowRecordId.value = null
}
}
)
onMounted(async () => {
await notificationStore.fetchUnreadCount()
await fetchNotifications()

View File

@ -1,22 +0,0 @@
import type { BorrowRecord, HrSyncBorrowedAssetPreview } from '../../types'
export const buildNotificationQuickReturnAssetKey = (
asset: Pick<HrSyncBorrowedAssetPreview, 'borrowRecordId' | 'equipmentCode'>
) => {
if (asset.borrowRecordId) {
return `borrow-record:${asset.borrowRecordId}`
}
return `equipment:${(asset.equipmentCode || '').trim().toUpperCase()}`
}
export const findNotificationQuickReturnRecord = (
borrowRecords: BorrowRecord[],
asset: Pick<HrSyncBorrowedAssetPreview, 'borrowRecordId'>
) => {
if (asset.borrowRecordId) {
return borrowRecords.find((record) => record.id === asset.borrowRecordId) || null
}
return borrowRecords.length === 1 ? borrowRecords[0] : null
}