From 02961ebf0e46f8d4c608e6f4ee72f5efe0a7e164 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 9 Nov 2026 11:46:10 +0800 Subject: [PATCH] feat: stock assets automatically on confirmed departure --- .env.example | 2 + .../migration.sql | 1 + backend/prisma/schema.prisma | 4 +- backend/src/controllers/borrowController.ts | 30 +-- .../assetMovementService.concurrency.test.ts | 2 + .../hrDepartureReturn.integration.test.ts | 189 ++++++++++++++++++ .../hrDepartureReturnService.test.ts | 37 ++++ backend/src/services/assetMovementService.ts | 30 +-- backend/src/services/borrowReturnService.ts | 45 +++++ .../src/services/hrDepartureReturnService.ts | 162 +++++++++++++++ backend/src/services/hrSyncService.ts | 60 ++++++ docker-compose.yml | 1 + 12 files changed, 520 insertions(+), 43 deletions(-) create mode 100644 backend/prisma/migrations/20261109_hr_system_asset_return/migration.sql create mode 100644 backend/src/services/__tests__/hrDepartureReturn.integration.test.ts create mode 100644 backend/src/services/__tests__/hrDepartureReturnService.test.ts create mode 100644 backend/src/services/borrowReturnService.ts create mode 100644 backend/src/services/hrDepartureReturnService.ts diff --git a/.env.example b/.env.example index 65f2129..a7b88b8 100644 --- a/.env.example +++ b/.env.example @@ -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=供应商,快递公司,外部交流 diff --git a/backend/prisma/migrations/20261109_hr_system_asset_return/migration.sql b/backend/prisma/migrations/20261109_hr_system_asset_return/migration.sql new file mode 100644 index 0000000..c05a2e1 --- /dev/null +++ b/backend/prisma/migrations/20261109_hr_system_asset_return/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `asset_transfer_record` MODIFY COLUMN `operated_by` INT NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e77ff32..c132d8d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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]) diff --git a/backend/src/controllers/borrowController.ts b/backend/src/controllers/borrowController.ts index da62360..5feae11 100644 --- a/backend/src/controllers/borrowController.ts +++ b/backend/src/controllers/borrowController.ts @@ -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, diff --git a/backend/src/services/__tests__/assetMovementService.concurrency.test.ts b/backend/src/services/__tests__/assetMovementService.concurrency.test.ts index e09b555..41d1326 100644 --- a/backend/src/services/__tests__/assetMovementService.concurrency.test.ts +++ b/backend/src/services/__tests__/assetMovementService.concurrency.test.ts @@ -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: { diff --git a/backend/src/services/__tests__/hrDepartureReturn.integration.test.ts b/backend/src/services/__tests__/hrDepartureReturn.integration.test.ts new file mode 100644 index 0000000..b2f97fd --- /dev/null +++ b/backend/src/services/__tests__/hrDepartureReturn.integration.test.ts @@ -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 = {}) => { + 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() } + } +}) diff --git a/backend/src/services/__tests__/hrDepartureReturnService.test.ts b/backend/src/services/__tests__/hrDepartureReturnService.test.ts new file mode 100644 index 0000000..c323b9c --- /dev/null +++ b/backend/src/services/__tests__/hrDepartureReturnService.test.ts @@ -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') +}) diff --git a/backend/src/services/assetMovementService.ts b/backend/src/services/assetMovementService.ts index 1c5df29..13218ef 100644 --- a/backend/src/services/assetMovementService.ts +++ b/backend/src/services/assetMovementService.ts @@ -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>(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>(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>( - 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 } ) => { diff --git a/backend/src/services/borrowReturnService.ts b/backend/src/services/borrowReturnService.ts new file mode 100644 index 0000000..86f08b4 --- /dev/null +++ b/backend/src/services/borrowReturnService.ts @@ -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 } +} diff --git a/backend/src/services/hrDepartureReturnService.ts b/backend/src/services/hrDepartureReturnService.ts new file mode 100644 index 0000000..f8561d7 --- /dev/null +++ b/backend/src/services/hrDepartureReturnService.ts @@ -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, + from: Date, + now: Date +) => { + const latest = new Map() + 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() + 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>(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>( + 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): Promise => { + 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 +} diff --git a/backend/src/services/hrSyncService.ts b/backend/src/services/hrSyncService.ts index 79686d1..34eafa6 100644 --- a/backend/src/services/hrSyncService.ts +++ b/backend/src/services/hrSyncService.ts @@ -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 => { 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() @@ -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> | 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, diff --git a/docker-compose.yml b/docker-compose.yml index 52d9112..afacbfb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-供应商,快递公司,外部交流}