feat: sync borrowed asset departments and notify HR outcomes
This commit is contained in:
parent
02961ebf0e
commit
ad0e130149
@ -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() }
|
||||
}
|
||||
})
|
||||
@ -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`)
|
||||
|
||||
@ -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+ 条变动未在消息中展开/)
|
||||
})
|
||||
|
||||
47
backend/src/services/hrAssetOutcomeStore.ts
Normal file
47
backend/src/services/hrAssetOutcomeStore.ts
Normal 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))})
|
||||
`)
|
||||
}
|
||||
169
backend/src/services/hrBorrowDepartmentService.ts
Normal file
169
backend/src/services/hrBorrowDepartmentService.ts
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
@ -780,6 +780,7 @@ export interface HrSyncBorrowedAssetPreview {
|
||||
export interface HrSyncPersonAfterSnapshot {
|
||||
displayName?: string | null
|
||||
assets?: HrSyncBorrowedAssetPreview[]
|
||||
assetOutcome?: { assets: HrSyncBorrowedAssetPreview[] }
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user