feat: stock assets automatically on confirmed departure

This commit is contained in:
WuYongJie 2026-11-09 11:46:10 +08:00
parent ab63d25806
commit 02961ebf0e
12 changed files with 520 additions and 43 deletions

View File

@ -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=供应商,快递公司,外部交流

View File

@ -0,0 +1 @@
ALTER TABLE `asset_transfer_record` MODIFY COLUMN `operated_by` INT NULL;

View File

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

View File

@ -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,

View File

@ -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: {

View File

@ -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() }
}
})

View File

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

View File

@ -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
}
) => {

View 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 }
}

View 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
}

View File

@ -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,

View File

@ -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:-供应商,快递公司,外部交流}