feat: stock assets automatically on confirmed departure
This commit is contained in:
parent
ab63d25806
commit
02961ebf0e
@ -21,6 +21,8 @@ SQLSERVER_TRUST_CERT=true
|
||||
HR_SYNC_ENABLED=false
|
||||
HR_SYNC_SCHEDULE=03:00
|
||||
HR_SYNC_SCHEDULES=
|
||||
# 离职自动入库起算时间,使用 UTC ISO 8601 格式。
|
||||
HR_SYNC_AUTO_RETURN_FROM=
|
||||
HR_SYNC_ALLOWED_STATUS_VALUES=3
|
||||
HR_SYNC_INACTIVE_STATUS_VALUES=5
|
||||
HR_SYNC_EXCLUDED_DEPARTMENTS=供应商,快递公司,外部交流
|
||||
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE `asset_transfer_record` MODIFY COLUMN `operated_by` INT NULL;
|
||||
@ -701,12 +701,12 @@ model AssetTransferRecord {
|
||||
to_department_id Int?
|
||||
to_department_name String? @db.VarChar(100)
|
||||
reason String? @db.Text
|
||||
operated_by Int
|
||||
operated_by Int?
|
||||
operated_at DateTime @default(now())
|
||||
created_at DateTime @default(now())
|
||||
|
||||
rootEquipment Equipment @relation("AssetTransferRoot", fields: [root_equipment_id], references: [id])
|
||||
operator User @relation("AssetTransferOperator", fields: [operated_by], references: [id])
|
||||
operator User? @relation("AssetTransferOperator", fields: [operated_by], references: [id], onDelete: Restrict, onUpdate: Cascade)
|
||||
items AssetTransferItem[]
|
||||
|
||||
@@index([root_equipment_id, operated_at])
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
sortParentAssetsFirst
|
||||
} from '../utils/equipmentSearchPriority'
|
||||
import { logOperation } from './operationLogController'
|
||||
import { completeLockedBorrowReturn } from '../services/borrowReturnService'
|
||||
import {
|
||||
assertReturnConditionSupported,
|
||||
borrowAssetTree,
|
||||
@ -288,34 +289,9 @@ const returnEquipmentInTransaction = async (
|
||||
const finalNotes = childrenDiff
|
||||
? [notes, `配件变更:移除${childrenDiff.removed.length}个,新增${childrenDiff.added.length}个`].filter(Boolean).join(';')
|
||||
: notes
|
||||
await createAssetMovementRecord(tx, {
|
||||
items,
|
||||
actionType: returnCondition === '丢失' ? 'loss' : 'return',
|
||||
toDepartmentId: null,
|
||||
operatedBy,
|
||||
reason: returnCondition === '丢失' ? `丢失登记(未入仓)${finalNotes ? `;${finalNotes}` : ''}` : finalNotes || '正常归还入仓'
|
||||
const { returnTime } = await completeLockedBorrowReturn(tx, {
|
||||
items, borrowRecord, returnCondition, operatedBy, notes: finalNotes
|
||||
})
|
||||
const currentRoot = items[0]
|
||||
const returnTime = new Date()
|
||||
const updatedBorrowRecord = await tx.borrowRecord.updateMany({
|
||||
where: {
|
||||
id: borrowRecord.id,
|
||||
equipment_id: currentRoot.id,
|
||||
status: '已借出'
|
||||
},
|
||||
data: {
|
||||
return_time: returnTime,
|
||||
status: '已归还',
|
||||
return_condition: returnCondition,
|
||||
notes: finalNotes
|
||||
}
|
||||
})
|
||||
|
||||
if (updatedBorrowRecord.count !== 1) {
|
||||
throw new Error('该借用记录已归还,请刷新后重试')
|
||||
}
|
||||
|
||||
await finalizeNormalReturnState(tx, currentRoot, returnCondition, items)
|
||||
|
||||
return {
|
||||
need_confirm: false as const,
|
||||
|
||||
@ -236,6 +236,7 @@ test('借出时锁定递归资产树并同步根资产、配件部门及递归
|
||||
$queryRaw: async (query: any) => {
|
||||
if (query.sql.startsWith('SELECT id FROM equipment')) return []
|
||||
if (query.sql.includes('FROM borrow_record')) return []
|
||||
if (query.sql.includes('FROM person')) return [{ ...borrowPerson, status: 'active' }]
|
||||
if (query.sql.includes('WHERE equipment.id = ?')) return [{ ...borrowRoot }]
|
||||
if (query.sql.includes('WHERE equipment.parent_id IN')) {
|
||||
return childrenForParents(query.values as number[]).map((item) => ({ ...item }))
|
||||
@ -394,6 +395,7 @@ test('同一资产并发重复借出时只有一个请求成功', async () => {
|
||||
if (query.sql.includes('FROM borrow_record')) {
|
||||
return records.map((record) => ({ id: record.id, equipment_id: record.equipment_id }))
|
||||
}
|
||||
if (query.sql.includes('FROM person')) return [{ ...borrowPerson, status: 'active' }]
|
||||
return []
|
||||
},
|
||||
equipment: {
|
||||
|
||||
@ -0,0 +1,189 @@
|
||||
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 { returnAssetsForHrDepartures, selectConfirmedHrDepartures } = await import('../hrDepartureReturnService')
|
||||
const { borrowAssetTree } = await import('../assetMovementService')
|
||||
const prefix = `hr-return-${randomUUID().slice(0, 10)}`
|
||||
const equipmentIds: number[] = []
|
||||
const personIds: number[] = []
|
||||
const departmentIds: number[] = []
|
||||
const storageIds: number[] = []
|
||||
const userIds: number[] = []
|
||||
const at = new Date('2026-11-09T12:00:00Z')
|
||||
const loanTime = new Date('2026-11-09T04:00:00Z')
|
||||
const from = new Date('2026-11-09T06:00:00Z')
|
||||
const now = new Date('2026-11-10T00:00:00Z')
|
||||
const dayEnd = new Date('2026-11-09T16:00:00Z')
|
||||
let next = 0
|
||||
try {
|
||||
const department = await prisma.department.create({ data: { name: prefix } }); departmentIds.push(department.id)
|
||||
const storage = await prisma.storageLocation.create({ data: { name: prefix } }); storageIds.push(storage.id)
|
||||
const user = await prisma.user.create({ data: { username: prefix, name: prefix, password: 'isolated-db-fixture' } }); userIds.push(user.id)
|
||||
const person = async (status = 'inactive') => {
|
||||
const row = await prisma.person.create({ data: {
|
||||
nickname: prefix, real_name: prefix, status, source_userid: `${prefix}-${++next}`,
|
||||
source_system: 'ding_hr', main_department_id: department.id
|
||||
} })
|
||||
personIds.push(row.id)
|
||||
return row
|
||||
}
|
||||
const equipment = async (extra: Partial<Prisma.EquipmentUncheckedCreateInput> = {}) => {
|
||||
const row = await prisma.equipment.create({ data: {
|
||||
equipment_code: `${prefix}-${++next}`, equipment_name: prefix, equipment_type: '离职测试',
|
||||
total_quantity: 1, remaining_quantity: 0, status: '已借出', unit_price: 100,
|
||||
total_value: 100, borrowed_value: 100, available_value: 0,
|
||||
department_id: department.id, storage_location_id: storage.id, ...extra
|
||||
} })
|
||||
equipmentIds.push(row.id)
|
||||
return row
|
||||
}
|
||||
const loan = (row: Equipment, borrower: Person, childrenSnapshot?: string, createdAt = loanTime, borrowTime = loanTime) => prisma.borrowRecord.create({ data: {
|
||||
equipment_id: row.id, equipment_code: row.equipment_code, person_id: borrower.id,
|
||||
person_name: borrower.real_name, person_nickname: borrower.nickname, notes: '原借用备注',
|
||||
children_snapshot: childrenSnapshot, borrow_time: borrowTime, created_at: createdAt, status: '已借出'
|
||||
} })
|
||||
const event = (borrower: Person, lastWorkDay = at, status = 2) => ({
|
||||
id: `${prefix}-event`, sourceUserId: borrower.source_userid, lastWorkDay: lastWorkDay.getTime(), status
|
||||
})
|
||||
const process = (borrowers: Person[]) => returnAssetsForHrDepartures(selectConfirmedHrDepartures(borrowers.map(row => event(row)), new Set(), from, now))
|
||||
|
||||
await t.test('复用正常归还并保留库位,清空主机配件归属,只写一条系统流水与审计', async () => {
|
||||
const borrower = await person()
|
||||
const root = await equipment()
|
||||
const child = await equipment({ parent_id: root.id, mount_status: 'mounted' })
|
||||
const child2 = await equipment({ parent_id: child.id, mount_status: 'mounted' })
|
||||
const record = await loan(root, borrower, JSON.stringify([{ id: child.id, parent_id: root.id }, { id: child2.id, parent_id: child.id }]))
|
||||
const first = await process([borrower])
|
||||
assert.deepEqual([first.returned, first.pending, first.failed], [1, 0, 0])
|
||||
assert.equal((await process([borrower])).returned, 0)
|
||||
for (const id of [root.id, child.id, child2.id]) {
|
||||
const row = await prisma.equipment.findUniqueOrThrow({ where: { id } })
|
||||
assert.equal(row.status, '可用'); assert.equal(row.department_id, null); assert.equal(row.storage_location_id, storage.id)
|
||||
assert.equal(row.remaining_quantity, 1); assert.equal(Number(row.available_value), 100); assert.equal(Number(row.borrowed_value), 0)
|
||||
}
|
||||
const saved = await prisma.borrowRecord.findUniqueOrThrow({ where: { id: record.id } })
|
||||
assert.equal(saved.status, '已归还'); assert.equal(saved.return_condition, '正常')
|
||||
assert.ok(saved.return_time); assert.equal(saved.notes, '原借用备注')
|
||||
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 } })
|
||||
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`)
|
||||
assert.equal(detail.movement_id, movements[0].id)
|
||||
})
|
||||
|
||||
await t.test('历史离职、未来离职、仍在名单、无确证停用以及事后补录均无写入', async () => {
|
||||
const people = await Promise.all(Array.from({ length: 7 }, () => person()))
|
||||
const fixtures = []
|
||||
for (let i = 0; i < people.length; i++) {
|
||||
const root = await equipment()
|
||||
fixtures.push(await loan(root, people[i], undefined, i === 4 ? dayEnd : loanTime, i === 5 ? dayEnd : loanTime))
|
||||
}
|
||||
const departures = selectConfirmedHrDepartures([
|
||||
event(people[0], new Date('2026-11-01T00:00:00Z')),
|
||||
event(people[1], new Date('2026-11-11T00:00:00Z'), 1),
|
||||
event(people[2]), event(people[4]), event(people[5]), event(people[6])
|
||||
], new Set([people[2].source_userid!]), from, now)
|
||||
await prisma.person.update({ where: { id: people[6].id }, data: { status: 'active' } })
|
||||
assert.equal((await returnAssetsForHrDepartures(departures)).returned, 0)
|
||||
const ids = fixtures.map(row => row.id)
|
||||
assert.equal(await prisma.borrowRecord.count({ where: { id: { in: ids }, status: '已借出', return_time: null } }), ids.length)
|
||||
})
|
||||
|
||||
await t.test('配件快照差异、库存异常、维修/报废、挂载树多持有人和独立配件借用留待人工', async () => {
|
||||
for (const scenario of ['changed-accessory', 'bad-stock', 'other-owner', 'child-borrow', 'maintenance', 'disposal']) {
|
||||
const borrower = await person()
|
||||
const root = await equipment(['other-owner', 'child-borrow', 'bad-stock'].includes(scenario) ? { total_quantity: 2 } : {})
|
||||
const child = scenario === 'other-owner' || scenario === 'child-borrow'
|
||||
? await equipment({ parent_id: root.id, mount_status: 'mounted' }) : undefined
|
||||
const snapshot = child ? JSON.stringify([{ id: child.id, parent_id: root.id }]) : undefined
|
||||
const record = await loan(root, borrower, snapshot)
|
||||
if (scenario === 'changed-accessory') await equipment({ parent_id: root.id, mount_status: 'mounted' })
|
||||
if (scenario === 'other-owner') await loan(root, await person('active'), snapshot)
|
||||
if (scenario === 'child-borrow') await loan(child!, await person('active'))
|
||||
if (scenario === 'maintenance') await prisma.maintenanceRecord.create({ data: {
|
||||
equipment_id: root.id, equipment_code: root.equipment_code, issue_description: prefix, estimated_cost: 0, created_by: user.id
|
||||
} })
|
||||
if (scenario === 'disposal') await prisma.disposalRecord.create({ data: {
|
||||
equipment_id: root.id, equipment_code: root.equipment_code, disposal_reason: prefix,
|
||||
disposal_method: '报废', estimated_value: 0, applied_by: user.id
|
||||
} })
|
||||
const result = await process([borrower])
|
||||
assert.deepEqual([result.returned, result.pending, result.failed], [0, 1, 0], scenario)
|
||||
assert.equal((await prisma.borrowRecord.findUniqueOrThrow({ where: { id: record.id } })).status, '已借出', scenario)
|
||||
assert.equal((await prisma.equipment.findUniqueOrThrow({ where: { id: root.id } })).remaining_quantity, 0, scenario)
|
||||
assert.equal(await prisma.assetTransferRecord.count({ where: { root_equipment_id: root.id } }), 0, scenario)
|
||||
}
|
||||
})
|
||||
|
||||
await t.test('无配件的多数量资产只归还离职者的一件,保留其他借用和现有部门', async () => {
|
||||
const borrower = await person()
|
||||
const root = await equipment({ total_quantity: 2, total_value: 200, borrowed_value: 200 })
|
||||
const departureLoan = await loan(root, borrower)
|
||||
const otherLoan = await loan(root, await person('active'))
|
||||
const result = await process([borrower])
|
||||
assert.deepEqual([result.returned, result.pending, result.failed], [1, 0, 0])
|
||||
const saved = await prisma.equipment.findUniqueOrThrow({ where: { id: root.id } })
|
||||
assert.equal(saved.remaining_quantity, 1); assert.equal(saved.department_id, department.id)
|
||||
assert.equal(saved.status, '可用'); assert.equal(Number(saved.available_value), 100)
|
||||
assert.equal(Number(saved.borrowed_value), 100)
|
||||
assert.equal((await prisma.borrowRecord.findUniqueOrThrow({ where: { id: departureLoan.id } })).status, '已归还')
|
||||
assert.deepEqual(await prisma.borrowRecord.findUniqueOrThrow({ where: { id: otherLoan.id } }), otherLoan)
|
||||
assert.equal((await process([borrower])).returned, 0)
|
||||
assert.equal(await prisma.assetTransferRecord.count({ where: { root_equipment_id: root.id } }), 1)
|
||||
})
|
||||
|
||||
await t.test('同一人多件逐笔归还,最终库存足量且不能重复恢复', async () => {
|
||||
const borrower = await person()
|
||||
const root = await equipment({ total_quantity: 2, total_value: 200, borrowed_value: 200 })
|
||||
await loan(root, borrower); await loan(root, borrower)
|
||||
assert.equal((await process([borrower])).returned, 2)
|
||||
assert.equal((await process([borrower])).returned, 0)
|
||||
const saved = await prisma.equipment.findUniqueOrThrow({ where: { id: root.id } })
|
||||
assert.equal(saved.remaining_quantity, 2); assert.equal(saved.department_id, null); assert.equal(Number(saved.available_value), 200)
|
||||
assert.equal(await prisma.assetTransferRecord.count({ where: { root_equipment_id: root.id } }), 2)
|
||||
})
|
||||
|
||||
await t.test('两个执行者争抢同一借用单只写一笔;借用事务重查人事停用', async () => {
|
||||
const borrower = await person()
|
||||
const root = await equipment()
|
||||
await loan(root, borrower)
|
||||
const outcomes = await Promise.all([process([borrower]), process([borrower])])
|
||||
assert.equal(outcomes.reduce((sum, result) => sum + result.returned, 0), 1)
|
||||
assert.equal(await prisma.assetTransferRecord.count({ where: { root_equipment_id: root.id } }), 1)
|
||||
const nextRoot = await equipment({ status: '可用', remaining_quantity: 1 })
|
||||
const stalePerson = { ...borrower, status: 'active' }
|
||||
await assert.rejects(prisma.$transaction(tx => borrowAssetTree(tx, { rootEquipmentId: nextRoot.id, person: stalePerson }),
|
||||
{ isolationLevel: 'ReadCommitted' }), /使用者已失效/)
|
||||
assert.equal(await prisma.borrowRecord.count({ where: { equipment_id: nextRoot.id } }), 0)
|
||||
})
|
||||
} finally {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.operationLog.deleteMany({ where: { equipment_id: { in: equipmentIds }, username: 'system-hr-sync' } })
|
||||
await tx.assetTransferRecord.deleteMany({ where: { root_equipment_id: { in: equipmentIds } } })
|
||||
await tx.maintenanceRecord.deleteMany({ where: { equipment_id: { in: equipmentIds } } })
|
||||
await tx.disposalRecord.deleteMany({ where: { 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.person.deleteMany({ where: { id: { in: personIds } } })
|
||||
await tx.storageLocation.deleteMany({ where: { id: { in: storageIds } } })
|
||||
await tx.department.deleteMany({ where: { id: { in: departmentIds } } })
|
||||
await tx.user.deleteMany({ where: { id: { in: userIds } } })
|
||||
}, { timeout: 20000 })
|
||||
} finally { await prisma.$disconnect() }
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { parseHrAutoReturnFrom, selectConfirmedHrDepartures } from '../hrDepartureReturnService'
|
||||
|
||||
test('离职自动入库截点缺省关闭,配置只接受有效 UTC ISO 时间', () => {
|
||||
assert.equal(parseHrAutoReturnFrom(undefined), null)
|
||||
assert.equal(parseHrAutoReturnFrom(' '), null)
|
||||
assert.equal(parseHrAutoReturnFrom('2026-11-09T06:00:00.125Z')?.toISOString(), '2026-11-09T06:00:00.125Z')
|
||||
for (const invalid of ['2026-11-09', '2026-11-09T08:00:00+08:00', '2026-02-30T00:00:00Z', 'soon']) {
|
||||
assert.throws(() => parseHrAutoReturnFrom(invalid), /HR_SYNC_AUTO_RETURN_FROM/)
|
||||
}
|
||||
})
|
||||
|
||||
test('仅选截点后已完成且生效的权威离职事件,仍在名单的未明确停用人员不入库', () => {
|
||||
const from = new Date('2026-11-09T06:00:00Z')
|
||||
const now = new Date('2026-11-10T16:30:00Z')
|
||||
const row = (id: string, sourceUserId: string, time: string, status = 2) => ({
|
||||
id, sourceUserId, status, lastWorkDay: String(Date.parse(time))
|
||||
})
|
||||
const selected = selectConfirmedHrDepartures([
|
||||
row('historical', 'past', '2026-11-09T05:59:59.999Z'),
|
||||
row('future', 'future', '2026-11-10T16:30:00.001Z'),
|
||||
row('pending', 'pending', '2026-11-10T12:00:00Z', 1),
|
||||
row('old-completed', 'rehired-pending', '2026-11-09T07:00:00Z'),
|
||||
row('new-pending', 'rehired-pending', '2026-11-11T12:00:00Z', 1),
|
||||
row('active', 'roster-active', '2026-11-10T12:00:00Z'),
|
||||
row('unknown', 'roster-unknown', '2026-11-10T12:00:00Z'),
|
||||
row('old', 'departed', '2026-11-09T06:00:00Z'),
|
||||
row('latest', 'departed', '2026-11-10T16:30:00Z'),
|
||||
row('boundary', 'at-cutoff', '2026-11-09T06:00:00Z'),
|
||||
row('invalid', 'invalid', 'invalid')
|
||||
], new Set(['roster-active', 'roster-unknown']), from, now)
|
||||
assert.deepEqual([...selected.keys()], ['departed', 'at-cutoff'])
|
||||
assert.equal(selected.get('departed')?.sourceEventId, 'latest')
|
||||
assert.equal(selected.get('departed')?.borrowBefore.toISOString(), '2026-11-11T16:00:00.000Z')
|
||||
assert.equal(selected.get('at-cutoff')?.borrowBefore.toISOString(), '2026-11-09T16:00:00.000Z')
|
||||
})
|
||||
@ -370,8 +370,18 @@ export const borrowAssetTree = async (
|
||||
throw new AssetTreeConflictError('资产树存在进行中的报废申请,不能借用')
|
||||
}
|
||||
|
||||
let borrowPerson = input.person
|
||||
let targetDepartmentId = input.person.main_department_id ?? root.department_id
|
||||
// Recheck the person after locking the asset tree, as HR sync can deactivate
|
||||
// them between the controller's lookup and the transaction.
|
||||
const people = await client.$queryRaw<Array<{
|
||||
id: number; real_name: string; nickname: string; job_number: string | null;
|
||||
status: string; main_department_id: number | null
|
||||
}>>(Prisma.sql`SELECT id, real_name, nickname, job_number, status, main_department_id
|
||||
FROM person WHERE id = ${input.person.id} FOR UPDATE`)
|
||||
const borrowPerson = people[0]
|
||||
if (!borrowPerson || borrowPerson.status !== 'active') {
|
||||
throw new AssetTreeConflictError('使用者已失效,请刷新人员后重试')
|
||||
}
|
||||
let targetDepartmentId = borrowPerson.main_department_id ?? root.department_id
|
||||
let correctionOperator: { id: number; username: string } | undefined
|
||||
let correctionDepartmentName: string | undefined
|
||||
if (inconsistentDepartment) {
|
||||
@ -383,18 +393,11 @@ export const borrowAssetTree = async (
|
||||
if (!Number.isSafeInteger(input.operatedBy) || !input.operatedBy || input.operatedBy <= 0) {
|
||||
throw new AssetTreeConflictError('部门不一致的资产树需要有效操作人才能留痕纠正,请先核查')
|
||||
}
|
||||
const people = await client.$queryRaw<Array<{
|
||||
id: number; real_name: string; nickname: string; job_number: string | null;
|
||||
status: string; main_department_id: number | null
|
||||
}>>(Prisma.sql`SELECT id, real_name, nickname, job_number, status, main_department_id
|
||||
FROM person WHERE id = ${input.person.id} FOR UPDATE`)
|
||||
const person = people[0]
|
||||
if (!person || person.status !== 'active' || !person.main_department_id ||
|
||||
person.main_department_id !== input.person.main_department_id) {
|
||||
throw new AssetTreeConflictError('使用者已失效、缺少主部门或部门已变化,请刷新人员后重试')
|
||||
if (!borrowPerson.main_department_id || borrowPerson.main_department_id !== input.person.main_department_id) {
|
||||
throw new AssetTreeConflictError('使用者缺少主部门或部门已变化,请刷新人员后重试')
|
||||
}
|
||||
const departments = await client.$queryRaw<Array<{ id: number; name: string; status: string; source_system: string | null }>>(
|
||||
Prisma.sql`SELECT id, name, status, source_system FROM department WHERE id = ${person.main_department_id} FOR UPDATE`
|
||||
Prisma.sql`SELECT id, name, status, source_system FROM department WHERE id = ${borrowPerson.main_department_id} FOR UPDATE`
|
||||
)
|
||||
const department = departments[0]
|
||||
if (!department || department.status !== 'active' || department.source_system !== HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM) {
|
||||
@ -409,7 +412,6 @@ export const borrowAssetTree = async (
|
||||
}
|
||||
correctionOperator = operators[0]
|
||||
correctionDepartmentName = department.name
|
||||
borrowPerson = person
|
||||
targetDepartmentId = department.id
|
||||
}
|
||||
const nextRemainingQuantity = root.remaining_quantity - 1
|
||||
@ -689,7 +691,7 @@ export const createAssetMovementRecord = async (
|
||||
actionType: 'transfer' | 'idle' | 'return' | 'loss'
|
||||
toDepartmentId: number | null
|
||||
toDepartmentName?: string | null
|
||||
operatedBy: number
|
||||
operatedBy: number | null
|
||||
reason?: string | null
|
||||
}
|
||||
) => {
|
||||
|
||||
45
backend/src/services/borrowReturnService.ts
Normal file
45
backend/src/services/borrowReturnService.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { createAssetMovementRecord, finalizeNormalReturnState, type AssetTreeItem } from './assetMovementService'
|
||||
|
||||
// The caller holds the asset-tree locks and has resolved any accessory changes.
|
||||
export const completeLockedBorrowReturn = async (
|
||||
client: Prisma.TransactionClient,
|
||||
input: {
|
||||
items: AssetTreeItem[]
|
||||
borrowRecord: { id: number; person_id: number }
|
||||
returnCondition: string
|
||||
notes?: string
|
||||
operatedBy: number | null
|
||||
reason?: string
|
||||
requireUnreturned?: boolean
|
||||
}
|
||||
) => {
|
||||
const root = input.items[0]
|
||||
const movement = await createAssetMovementRecord(client, {
|
||||
items: input.items,
|
||||
actionType: input.returnCondition === '丢失' ? 'loss' : 'return',
|
||||
toDepartmentId: null,
|
||||
operatedBy: input.operatedBy,
|
||||
reason: input.reason ?? (input.returnCondition === '丢失'
|
||||
? `丢失登记(未入仓)${input.notes ? `;${input.notes}` : ''}`
|
||||
: input.notes || '正常归还入仓')
|
||||
})
|
||||
const returnTime = new Date()
|
||||
const updated = await client.borrowRecord.updateMany({
|
||||
where: {
|
||||
id: input.borrowRecord.id,
|
||||
equipment_id: root.id,
|
||||
status: '已借出',
|
||||
...(input.requireUnreturned ? { person_id: input.borrowRecord.person_id, return_time: null } : {})
|
||||
},
|
||||
data: {
|
||||
return_time: returnTime,
|
||||
status: '已归还',
|
||||
return_condition: input.returnCondition,
|
||||
notes: input.notes
|
||||
}
|
||||
})
|
||||
if (updated.count !== 1) throw new Error('该借用记录已归还,请刷新后重试')
|
||||
const returnState = await finalizeNormalReturnState(client, root, input.returnCondition, input.items)
|
||||
return { returnTime, returnState, movementId: movement.id }
|
||||
}
|
||||
162
backend/src/services/hrDepartureReturnService.ts
Normal file
162
backend/src/services/hrDepartureReturnService.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import prisma from '../utils/prisma'
|
||||
import { parseBusinessDate } from '../utils/businessDate'
|
||||
import { getShanghaiDayRange } from '../utils/dashboardStatistics'
|
||||
import { HR_SOURCE_SYSTEM } from '../utils/organization'
|
||||
import { getReturnChildrenDiff } from '../utils/returnSnapshot'
|
||||
import { AssetTreeConflictError, loadAndLockAssetTree } from './assetMovementService'
|
||||
import { completeLockedBorrowReturn } from './borrowReturnService'
|
||||
|
||||
export type HrDepartureSourceRow = {
|
||||
id: unknown
|
||||
sourceUserId: unknown
|
||||
lastWorkDay: unknown
|
||||
status: unknown
|
||||
}
|
||||
|
||||
export type HrDeparture = {
|
||||
sourceEventId: string
|
||||
sourceUserId: string
|
||||
lastWorkDay: Date
|
||||
borrowBefore: Date
|
||||
}
|
||||
|
||||
export const parseHrAutoReturnFrom = (configured: string | undefined) => {
|
||||
const value = configured?.trim()
|
||||
if (!value) return null
|
||||
try {
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)) throw new Error()
|
||||
return parseBusinessDate(value, '离职入库生效时间')
|
||||
} catch {
|
||||
throw new Error('HR_SYNC_AUTO_RETURN_FROM 必须是有效的 UTC ISO 时间,例如 2026-11-09T08:00:00Z')
|
||||
}
|
||||
}
|
||||
|
||||
export const selectConfirmedHrDepartures = (
|
||||
rows: HrDepartureSourceRow[],
|
||||
rosterNotExplicitlyInactive: ReadonlySet<string>,
|
||||
from: Date,
|
||||
now: Date
|
||||
) => {
|
||||
const latest = new Map<string, { sourceEventId: string; epochMs: number; status: string }>()
|
||||
for (const row of rows) {
|
||||
const sourceUserId = String(row.sourceUserId ?? '').trim()
|
||||
const sourceEventId = String(row.id ?? '').trim()
|
||||
const epochMs = Number(row.lastWorkDay)
|
||||
if (!sourceUserId || !sourceEventId || !Number.isSafeInteger(epochMs) || epochMs <= 0) continue
|
||||
const previous = latest.get(sourceUserId)
|
||||
if (previous && previous.epochMs >= epochMs) continue
|
||||
latest.set(sourceUserId, { sourceEventId, epochMs, status: String(row.status) })
|
||||
}
|
||||
const departures = new Map<string, HrDeparture>()
|
||||
for (const [sourceUserId, { sourceEventId, epochMs, status }] of latest) {
|
||||
if (rosterNotExplicitlyInactive.has(sourceUserId) || status !== '2'
|
||||
|| epochMs < from.getTime() || epochMs > now.getTime()) continue
|
||||
const lastWorkDay = new Date(epochMs)
|
||||
departures.set(sourceUserId, { sourceUserId, sourceEventId, lastWorkDay, borrowBefore: getShanghaiDayRange(lastWorkDay).end })
|
||||
}
|
||||
return departures
|
||||
}
|
||||
|
||||
type BorrowCandidate = { id: number; equipment_id: number; person_id: number }
|
||||
|
||||
const returnConfirmedDepartureBorrow = async (candidate: BorrowCandidate, departure: HrDeparture) => (
|
||||
prisma.$transaction(async (tx) => {
|
||||
const items = await loadAndLockAssetTree(tx, candidate.equipment_id)
|
||||
const root = items[0]
|
||||
const equipmentIds = items.map(item => item.id)
|
||||
const activeBorrows = await tx.$queryRaw<Array<BorrowCandidate & {
|
||||
return_time: Date | null; borrow_time: Date; created_at: Date; children_snapshot: string | null
|
||||
}>>(Prisma.sql`
|
||||
SELECT id, equipment_id, person_id, return_time, borrow_time, created_at, children_snapshot
|
||||
FROM borrow_record WHERE equipment_id IN (${Prisma.join(equipmentIds)}) AND status = ${'已借出'}
|
||||
ORDER BY id FOR UPDATE
|
||||
`)
|
||||
const record = activeBorrows.find(row => row.id === candidate.id)
|
||||
if (!record || record.person_id !== candidate.person_id || record.return_time !== null
|
||||
|| record.borrow_time >= departure.borrowBefore || record.created_at >= departure.borrowBefore) return false
|
||||
|
||||
const people = await tx.$queryRaw<Array<{ status: string; source_userid: string | null; source_system: string | null }>>(
|
||||
Prisma.sql`SELECT status, source_userid, source_system FROM person WHERE id = ${record.person_id} FOR UPDATE`
|
||||
)
|
||||
const person = people[0]
|
||||
if (!person || person.status !== 'inactive' || person.source_userid !== departure.sourceUserId
|
||||
|| person.source_system !== HR_SOURCE_SYSTEM) return false
|
||||
|
||||
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 ? '可用' : '已借出')
|
||||
|| activeBorrows.length !== root.total_quantity - root.remaining_quantity
|
||||
|| 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('资产树库存、借用状态或持有人需要人工核查')
|
||||
}
|
||||
if (getReturnChildrenDiff(items, record)) {
|
||||
throw new AssetTreeConflictError('当前挂载配件与借出时不一致,需要人工确认')
|
||||
}
|
||||
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) throw new AssetTreeConflictError('资产树存在未完成的维修或报废,需要人工核查')
|
||||
|
||||
const reason = `正式离职自动归还入仓(借用单 ${record.id})`
|
||||
const { movementId } = await completeLockedBorrowReturn(tx, {
|
||||
items, borrowRecord: record, returnCondition: '正常', operatedBy: null, reason, requireUnreturned: true
|
||||
})
|
||||
await tx.operationLog.create({ data: {
|
||||
user_id: null, username: 'system-hr-sync', operation_type: '离职资产自动入库',
|
||||
equipment_id: root.id, equipment_code: root.equipment_code,
|
||||
operation_details: JSON.stringify({
|
||||
reason, source_event_id: departure.sourceEventId, source_userid: departure.sourceUserId,
|
||||
last_work_day: departure.lastWorkDay.toISOString(), person_id: record.person_id,
|
||||
borrow_record_id: record.id, movement_id: movementId, equipment_ids: equipmentIds
|
||||
})
|
||||
} })
|
||||
return true
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
||||
)
|
||||
|
||||
export type HrDepartureReturnResult = {
|
||||
status: 'completed' | 'failed'
|
||||
returned: number
|
||||
pending: number
|
||||
failed: number
|
||||
samples: Array<{ borrow_record_id: number; equipment_id: number; reason: string }>
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const returnAssetsForHrDepartures = async (departures: ReadonlyMap<string, HrDeparture>): Promise<HrDepartureReturnResult> => {
|
||||
const result: HrDepartureReturnResult = { status: 'completed', returned: 0, pending: 0, failed: 0, samples: [] }
|
||||
if (!departures.size) return result
|
||||
const candidates = await prisma.borrowRecord.findMany({
|
||||
where: {
|
||||
status: '已借出', return_time: null,
|
||||
person: { status: 'inactive', source_system: HR_SOURCE_SYSTEM, source_userid: { in: [...departures.keys()] } }
|
||||
},
|
||||
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 } } }
|
||||
})
|
||||
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
|
||||
try {
|
||||
if (await returnConfirmedDepartureBorrow(candidate, departure)) result.returned++
|
||||
} 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 : '自动入库事务失败,下一次同步会重试'
|
||||
})
|
||||
if (!conflict) console.error('[hr-sync] automatic return failed for borrow', candidate.id, error)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -23,6 +23,13 @@ import {
|
||||
findActiveDingTalkUsersByRoles,
|
||||
findActiveStationUsersByRoles
|
||||
} from './businessNotificationService'
|
||||
import {
|
||||
parseHrAutoReturnFrom,
|
||||
returnAssetsForHrDepartures,
|
||||
selectConfirmedHrDepartures,
|
||||
type HrDepartureReturnResult,
|
||||
type HrDepartureSourceRow
|
||||
} from './hrDepartureReturnService'
|
||||
|
||||
type SourceDepartment = {
|
||||
sourceGroupId: number | null
|
||||
@ -96,6 +103,7 @@ type HrSyncResult = {
|
||||
deleted_legacy_departments: number
|
||||
deleted_inactive_business_departments: number
|
||||
}
|
||||
auto_return?: HrDepartureReturnResult
|
||||
sync_job_id?: number
|
||||
changes_count?: number
|
||||
}
|
||||
@ -652,6 +660,34 @@ const getSourcePersons = async (pool: any): Promise<SourcePersonCollection> => {
|
||||
return buildSourcePersonCollection(persons, specialExclusionSummary)
|
||||
}
|
||||
|
||||
const getConfirmedHrDepartures = async (pool: any, from: Date, now: Date) => {
|
||||
const schema = getSourceSchema()
|
||||
const { recordset } = await pool.request()
|
||||
.input('from', sql.BigInt, from.getTime())
|
||||
.input('now', sql.BigInt, now.getTime())
|
||||
.query(`
|
||||
WITH latest_departure AS (
|
||||
SELECT id, LTRIM(RTRIM(userid)) AS sourceUserId, last_work_day AS lastWorkDay, status,
|
||||
ROW_NUMBER() OVER (PARTITION BY LTRIM(RTRIM(userid)) ORDER BY last_work_day DESC, id DESC) AS row_number
|
||||
FROM ${schema}.hr_employee_dimission
|
||||
WHERE last_work_day >= @from AND NULLIF(LTRIM(RTRIM(userid)), '') IS NOT NULL
|
||||
)
|
||||
SELECT d.id, d.sourceUserId, d.lastWorkDay, d.status
|
||||
FROM latest_departure d
|
||||
WHERE d.row_number = 1 AND d.status = 2 AND d.lastWorkDay <= @now
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ${schema}.hr_employee_register r
|
||||
WHERE LTRIM(RTRIM(r.userid)) = d.sourceUserId
|
||||
AND ISNULL(LTRIM(RTRIM(CONVERT(varchar(50), r.[sys01-employeeStatus]))), '') <> '5'
|
||||
)
|
||||
`) as { recordset: HrDepartureSourceRow[] }
|
||||
return selectConfirmedHrDepartures(recordset, new Set(), from, now)
|
||||
}
|
||||
|
||||
const failedAutoReturn = (error: string): HrDepartureReturnResult => ({
|
||||
status: 'failed', returned: 0, pending: 0, failed: 0, samples: [], error
|
||||
})
|
||||
|
||||
const buildBusinessDepartmentCandidates = (persons: SourcePerson[]): BusinessDepartmentCandidate[] => {
|
||||
const map = new Map<string, BusinessDepartmentCandidate>()
|
||||
|
||||
@ -1857,10 +1893,34 @@ export const runHrSync = async (
|
||||
const remappedLegacyPersonDepartments = await cleanupLegacyPersonDepartments(localDepartmentMap)
|
||||
const equipmentDepartmentMigration = await migrateEquipmentDepartmentsToBusinessDepartments()
|
||||
const departmentCleanupResult = await cleanupLegacyDepartments()
|
||||
let autoReturn: HrDepartureReturnResult | undefined
|
||||
let departures: Awaited<ReturnType<typeof getConfirmedHrDepartures>> | undefined
|
||||
try {
|
||||
const from = parseHrAutoReturnFrom(process.env.HR_SYNC_AUTO_RETURN_FROM)
|
||||
if (from) {
|
||||
try {
|
||||
departures = await getConfirmedHrDepartures(pool, from, new Date())
|
||||
} catch (error) {
|
||||
console.error('[hr-sync] failed to read confirmed departures:', error)
|
||||
autoReturn = failedAutoReturn('无法读取正式离职记录,组织同步继续,未执行自动入库')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
autoReturn = failedAutoReturn((error as Error).message)
|
||||
}
|
||||
if (departures) {
|
||||
try {
|
||||
autoReturn = await returnAssetsForHrDepartures(departures)
|
||||
} catch (error) {
|
||||
console.error('[hr-sync] failed to load departure borrow candidates:', error)
|
||||
autoReturn = failedAutoReturn('无法读取离职人员未归还的借用,未执行自动入库')
|
||||
}
|
||||
}
|
||||
|
||||
const result: HrSyncResult = {
|
||||
departments: departmentResult,
|
||||
persons: personResult,
|
||||
...(autoReturn ? { auto_return: autoReturn } : {}),
|
||||
equipment_department_migration: equipmentDepartmentMigration,
|
||||
cleanup: {
|
||||
deleted_inactive_persons: deletedInactivePersons,
|
||||
|
||||
@ -54,6 +54,7 @@ services:
|
||||
HR_SYNC_ENABLED: ${HR_SYNC_ENABLED:-false}
|
||||
HR_SYNC_SCHEDULE: ${HR_SYNC_SCHEDULE:-03:00}
|
||||
HR_SYNC_SCHEDULES: ${HR_SYNC_SCHEDULES:-}
|
||||
HR_SYNC_AUTO_RETURN_FROM: ${HR_SYNC_AUTO_RETURN_FROM:-}
|
||||
HR_SYNC_ALLOWED_STATUS_VALUES: ${HR_SYNC_ALLOWED_STATUS_VALUES:-2,3}
|
||||
HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-5}
|
||||
HR_SYNC_EXCLUDED_DEPARTMENTS: ${HR_SYNC_EXCLUDED_DEPARTMENTS:-供应商,快递公司,外部交流}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user