diff --git a/backend/src/controllers/batchController.ts b/backend/src/controllers/batchController.ts index 2f83d92..132dcd1 100644 --- a/backend/src/controllers/batchController.ts +++ b/backend/src/controllers/batchController.ts @@ -30,7 +30,8 @@ const batchBorrowTransaction = async ( person: any, expectedReturnTime?: Date | null, isLongTerm?: boolean, - notes?: string + notes?: string, + operatedBy?: number ) => { const finalNotes = buildBorrowNotes(notes, isLongTerm) return prisma.$transaction( @@ -41,6 +42,7 @@ const batchBorrowTransaction = async ( ? null : (expectedReturnTime || null), isLongTerm, + operatedBy, notes: finalNotes }), { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted } @@ -141,7 +143,8 @@ export const batchBorrow = async (req: Request, res: Response) => { person, expectedReturn, is_long_term, - notes + notes, + res.locals.user?.id ) results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'success', message: '借出成功' }) diff --git a/backend/src/controllers/borrowController.ts b/backend/src/controllers/borrowController.ts index dd3f9a7..da62360 100644 --- a/backend/src/controllers/borrowController.ts +++ b/backend/src/controllers/borrowController.ts @@ -200,7 +200,8 @@ const borrowEquipmentInTransaction = async ( person: any, expectedReturn: Date | null, combinedNotes?: string, - isLongTerm?: boolean + isLongTerm?: boolean, + operatedBy?: number ) => { return prisma.$transaction( (tx) => borrowAssetTree(tx, { @@ -208,6 +209,7 @@ const borrowEquipmentInTransaction = async ( person, expectedReturnTime: expectedReturn, isLongTerm, + operatedBy, notes: combinedNotes }), { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted } @@ -537,7 +539,8 @@ export const borrowEquipment = async (req: Request, res: Response) => { person, expectedReturn, combinedNotes, - longTerm + longTerm, + res.locals.user?.id ) successResponse( @@ -591,7 +594,8 @@ export const scanBorrow = async (req: Request, res: Response) => { person, expectedReturn, combinedNotes, - longTerm + longTerm, + res.locals.user?.id ) successResponse( @@ -672,7 +676,8 @@ export const scanBorrowBatch = async (req: Request, res: Response) => { person, expectedReturn, combinedNotes, - longTerm + longTerm, + res.locals.user?.id ) results.push({ equipment_code: code, status: 'success', message: '出库成功' }) diff --git a/backend/src/services/__tests__/borrowDepartmentCorrection.integration.test.ts b/backend/src/services/__tests__/borrowDepartmentCorrection.integration.test.ts new file mode 100644 index 0000000..1a45d68 --- /dev/null +++ b/backend/src/services/__tests__/borrowDepartmentCorrection.integration.test.ts @@ -0,0 +1,269 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { randomUUID } from 'node:crypto' +import type { Person } from '@prisma/client' + +const assertLocalDatabase = (raw: string) => { + const url = new URL(raw) + assert.ok(url.protocol === 'mysql:' && ['127.0.0.1', 'localhost'].includes(url.hostname) + && url.port === '13309' && url.pathname === '/equipment_audit' && !url.search && !url.hash, + 'Only localhost:13309/equipment_audit is allowed; no URL overrides') +} + +test('department correction integration refuses remote databases and URL overrides', () => { + for (const url of ['mysql://unused@remote.invalid:13309/equipment_audit', + 'mysql://unused@localhost:3306/equipment_audit', 'mysql://unused@localhost:13309/other', + 'mysql://unused@localhost:13309/equipment_audit?socket=/tmp/mysql.sock']) { + assert.throws(() => assertLocalDatabase(url), /Only localhost/) + } +}) + +// Intentionally no .env loading and no application entry point. All data and +// derived rows belong to UUID-prefixed fixtures, removed by exact IDs in finally. +test('borrow historical department correction in isolated real MySQL', { + skip: process.env.RUN_BUSINESS_DB_TESTS !== 'true' +}, async (t) => { + assertLocalDatabase(process.env.DATABASE_URL || '') + const prisma = (await import('../../utils/prisma')).default + const { borrowAssetTree } = await import('../assetMovementService') + const { borrowEquipment, scanBorrow, scanBorrowBatch, scanReturn } = await import('../../controllers/borrowController') + const { batchBorrow } = await import('../../controllers/batchController') + const prefix = `dept-${randomUUID().slice(0, 12)}` + const equipmentIds: number[] = [] + const personIds: number[] = [] + const departmentIds: number[] = [] + const userIds: number[] = [] + let sequence = 0 + const response = (user: any) => { + const state = { status: 200, body: null as any } + const res = { locals: { user }, status(code: number) { state.status = code; return this }, + json(body: any) { state.body = body; return this } } + return { state, res } + } + const equipment = async (extra: Record) => { + const row = await prisma.equipment.create({ data: { + equipment_code: `${prefix}-${++sequence}`, equipment_name: prefix, equipment_type: 'test-only', + status: '可用', total_quantity: 1, remaining_quantity: 1, + unit_price: 100, total_value: 100, available_value: 100, ...extra + } }) + equipmentIds.push(row.id) + return row + } + const readTree = (ids: number[]) => prisma.equipment.findMany({ where: { id: { in: ids } }, orderBy: { id: 'asc' } }) + const auditCounts = async (rootId: number) => [ + await prisma.borrowRecord.count({ where: { equipment_id: rootId } }), + await prisma.assetTransferRecord.count({ where: { root_equipment_id: rootId } }), + await prisma.operationLog.count({ where: { equipment_id: rootId } }) + ] + try { + const user = await prisma.user.create({ data: { username: prefix, name: prefix, password: 'local-test-only', role: 'it' } }) + userIds.push(user.id) + for (let i = 0; i < 4; i++) { + const row = await prisma.department.create({ data: { name: `${prefix}-${i}`, status: 'active', source_system: 'ding_hr_business' } }) + departmentIds.push(row.id) + } + const [oldRootDepartment, oldChildDepartment, targetDepartment, otherTarget] = departmentIds + const people: Person[] = [] + for (const departmentId of [targetDepartment, otherTarget]) { + const person = await prisma.person.create({ data: { nickname: prefix, real_name: `${prefix}-${people.length}`, + status: 'active', main_department_id: departmentId } }) + personIds.push(person.id); people.push(person) + } + const person = people[0] + const tree = async (children = 1, consistent = false) => { + const root = await equipment({ department_id: oldRootDepartment }) + const rows = [root] + for (let i = 0; i < children; i++) rows.push(await equipment({ + parent_id: i === 4 ? rows[1].id : root.id, mount_status: 'mounted', + department_id: consistent ? oldRootDepartment : i === 3 ? null : oldChildDepartment + })) + return { root, rows, ids: rows.map(row => row.id) } + } + const borrow = (rootId: number, targetPerson = person, extra: Record = {}) => prisma.$transaction( + tx => borrowAssetTree(tx, { rootEquipmentId: rootId, person: targetPerson, operatedBy: user.id, ...extra }), + { isolationLevel: 'ReadCommitted', timeout: 20000, maxWait: 10000 } + ) + + await t.test('root plus five recursive accessories correct once, preserve actual operator and pre-correction audit, then return to storage', async () => { + const sample = await tree(5) + const out = response(user) + await scanBorrow({ ip: '127.0.0.1', body: { equipment_code: sample.root.equipment_code, + person_id: person.id, is_long_term: true } } as any, out.res as any) + assert.equal(out.state.status, 200, JSON.stringify(out.state.body)) + const record = await prisma.borrowRecord.findUniqueOrThrow({ where: { id: out.state.body.data.id } }) + assert.equal(record.person_id, person.id); assert.equal(record.is_long_term, true) + assert.equal(record.expected_return_time, null) + const borrowed = await readTree(sample.ids) + for (const row of borrowed) { + assert.equal(row.department_id, targetDepartment); assert.equal(row.status, '已借出') + assert.equal(row.remaining_quantity, 0); assert.equal(Number(row.available_value), 0) + assert.equal(row.parent_id, sample.rows.find(old => old.id === row.id)!.parent_id) + } + const snapshot = JSON.parse(record.children_snapshot!) + assert.equal(snapshot.length, 5) + assert.ok(snapshot.every((child: any) => child.department_id === targetDepartment && child.status === '可用')) + assert.equal(snapshot.find((child: any) => child.id === sample.rows[5].id).depth, 2) + const movement = await prisma.assetTransferRecord.findFirstOrThrow({ + where: { root_equipment_id: sample.root.id }, include: { items: true } + }) + assert.equal(movement.operated_by, user.id); assert.equal(movement.action_type, 'transfer') + assert.match(movement.reason!, new RegExp(`借用单 ${record.id}`)) + assert.equal(movement.items.length, 6) + for (const item of movement.items) { + assert.equal(item.from_department_id, sample.rows.find(row => row.id === item.equipment_id)!.department_id) + assert.equal(item.to_department_id, targetDepartment) + } + const log = await prisma.operationLog.findFirstOrThrow({ where: { equipment_id: sample.root.id, operation_type: '借出部门纠正' } }) + assert.equal(log.user_id, user.id); assert.equal(log.username, user.username) + const details = JSON.parse(log.operation_details!) + assert.equal(details.borrow_record_id, record.id); assert.equal(details.person_id, person.id) + assert.equal(details.operated_by, user.id); assert.equal(details.assets.length, 6) + const returned = response(user) + await scanReturn({ body: { equipment_code: sample.root.equipment_code, borrow_record_id: record.id } } as any, returned.res as any) + assert.equal(returned.state.status, 200, JSON.stringify(returned.state.body)) + assert.notEqual(returned.state.body.data.need_confirm, true) + for (const row of await readTree(sample.ids)) { + assert.equal(row.status, '可用'); assert.equal(row.remaining_quantity, 1) + assert.equal(row.department_id, null); assert.equal(Number(row.available_value), 100) + } + assert.equal((await prisma.borrowRecord.findUniqueOrThrow({ where: { id: record.id } })).status, '已归还') + const returnMovement = await prisma.assetTransferRecord.findFirstOrThrow({ + where: { root_equipment_id: sample.root.id, action_type: 'return' }, include: { items: true } + }) + assert.ok(returnMovement.items.every(item => item.from_department_id === targetDepartment && item.to_department_id === null)) + }) + + await t.test('normal borrow and both batch entry points pass the authenticated operator', async () => { + for (const handler of [borrowEquipment, scanBorrowBatch, batchBorrow]) { + const sample = await tree() + const out = response(user) + await handler({ ip: '127.0.0.1', body: { equipment_id: sample.root.id, + equipment_ids: [sample.root.id], equipment_code: sample.root.equipment_code, + equipment_codes: [sample.root.equipment_code], person_id: person.id, is_long_term: true } } as any, out.res as any) + assert.equal(out.state.status, 200, JSON.stringify(out.state.body)) + if (handler !== borrowEquipment) assert.equal(out.state.body.data.success_count, 1, JSON.stringify(out.state.body)) + assert.equal(await prisma.borrowRecord.count({ where: { equipment_id: sample.root.id, person_id: person.id } }), 1) + assert.equal((await prisma.assetTransferRecord.findFirstOrThrow({ where: { root_equipment_id: sample.root.id } })).operated_by, user.id) + } + }) + + await t.test('two distinct borrowers racing on one tree produce one borrow, one correction and one consistent owner', async () => { + const sample = await tree(5) + const results = await Promise.allSettled(people.map(target => borrow(sample.root.id, target))) + assert.equal(results.filter(result => result.status === 'fulfilled').length, 1) + const record = await prisma.borrowRecord.findFirstOrThrow({ where: { equipment_id: sample.root.id } }) + const winner = people.find(target => target.id === record.person_id)! + assert.ok((await readTree(sample.ids)).every(row => row.department_id === winner.main_department_id && row.remaining_quantity === 0)) + assert.deepEqual(await auditCounts(sample.root.id), [1, 1, 1]) + }) + + await t.test('unavailable or stale person, invalid main department and absent operator cannot correct', async () => { + for (const scenario of ['inactive', 'missing_department', 'changed_department', 'inactive_department', 'hr_only_department', 'manual_department', 'no_operator', 'unknown_operator', 'inactive_operator', 'demoted_operator']) { + const sample = await tree() + const before = await readTree(sample.ids) + const stalePerson = { ...person } + try { + if (scenario === 'inactive') await prisma.person.update({ where: { id: person.id }, data: { status: 'inactive' } }) + if (scenario === 'missing_department') { + await prisma.person.update({ where: { id: person.id }, data: { main_department_id: null } }) + stalePerson.main_department_id = null + } + if (scenario === 'changed_department') await prisma.person.update({ where: { id: person.id }, data: { main_department_id: otherTarget } }) + if (scenario === 'inactive_department') await prisma.department.update({ where: { id: targetDepartment }, data: { status: 'inactive' } }) + if (scenario === 'hr_only_department') await prisma.department.update({ where: { id: targetDepartment }, data: { source_system: 'ding_hr' } }) + if (scenario === 'manual_department') await prisma.department.update({ where: { id: targetDepartment }, data: { source_system: null } }) + if (scenario === 'inactive_operator') await prisma.user.update({ where: { id: user.id }, data: { status: 'inactive' } }) + if (scenario === 'demoted_operator') await prisma.user.update({ where: { id: user.id }, data: { role: 'administrative' } }) + await assert.rejects(borrow(sample.root.id, stalePerson, { + operatedBy: scenario === 'no_operator' ? undefined : scenario === 'unknown_operator' ? 2147483647 : user.id + }), /使用者|操作人/) + assert.deepEqual(await readTree(sample.ids), before, scenario) + assert.deepEqual(await auditCounts(sample.root.id), [0, 0, 0], scenario) + } finally { + await prisma.person.update({ where: { id: person.id }, data: { status: 'active', main_department_id: targetDepartment } }) + await prisma.department.update({ where: { id: targetDepartment }, data: { status: 'active', source_system: 'ding_hr_business' } }) + await prisma.user.update({ where: { id: user.id }, data: { status: 'active', role: 'it' } }) + } + } + }) + + await t.test('wrong stock, partial root borrowing, accessory occupancy, maintenance and disposal never receive department correction', async () => { + for (const scenario of ['child_stock', 'child_status', 'root_stock', 'partial_root', 'child_borrow', 'maintenance', 'disposal']) { + const sample = await tree() + if (scenario === 'child_stock') await prisma.equipment.update({ where: { id: sample.rows[1].id }, data: { remaining_quantity: 0 } }) + if (scenario === 'child_status') await prisma.equipment.update({ where: { id: sample.rows[1].id }, data: { status: '维护中' } }) + if (scenario === 'root_stock') await prisma.equipment.update({ where: { id: sample.root.id }, data: { total_quantity: 2, remaining_quantity: 1 } }) + if (scenario === 'partial_root') { + await prisma.equipment.update({ where: { id: sample.root.id }, data: { total_quantity: 2, remaining_quantity: 1 } }) + await prisma.borrowRecord.create({ data: { equipment_id: sample.root.id, equipment_code: sample.root.equipment_code, + person_id: people[1].id, person_name: people[1].real_name, person_nickname: people[1].nickname } }) + } + if (scenario === 'child_borrow') await prisma.borrowRecord.create({ data: { equipment_id: sample.rows[1].id, + equipment_code: sample.rows[1].equipment_code, person_id: people[1].id, + person_name: people[1].real_name, person_nickname: people[1].nickname } }) + if (scenario === 'maintenance') await prisma.maintenanceRecord.create({ data: { + equipment_id: sample.rows[1].id, equipment_code: sample.rows[1].equipment_code, + issue_description: prefix, estimated_cost: 0, created_by: user.id + } }) + if (scenario === 'disposal') await prisma.disposalRecord.create({ data: { + equipment_id: sample.rows[1].id, equipment_code: sample.rows[1].equipment_code, + disposal_reason: prefix, disposal_method: '报废', estimated_value: 0, applied_by: user.id + } }) + const before = await readTree(sample.ids) + const counts = await auditCounts(sample.root.id) + await assert.rejects(borrow(sample.root.id), /库存|借用|维修|报废|维护/) + assert.deepEqual(await readTree(sample.ids), before, scenario) + assert.deepEqual(await auditCounts(sample.root.id), counts, scenario) + } + }) + + await t.test('a real database write error rolls back department, stock, borrow and both audit records', async () => { + const sample = await tree(5) + const before = await readTree(sample.ids) + await assert.rejects(prisma.$transaction(async tx => { + await borrowAssetTree(tx, { rootEquipmentId: sample.root.id, person, operatedBy: user.id }) + // An actual FK error after all business and audit writes: no mocked DB. + await tx.operationLog.create({ data: { user_id: 2147483647, operation_type: 'test-only-rollback' } }) + }, { isolationLevel: 'ReadCommitted', timeout: 20000 }), /Foreign key|foreign key|P2003/) + assert.deepEqual(await readTree(sample.ids), before) + assert.deepEqual(await auditCounts(sample.root.id), [0, 0, 0]) + // Borrow record failure happens after the equipment CAS updates, and must + // also undo every corrected department before the transaction returns. + await assert.rejects(borrow(sample.root.id, person, { notes: 'x'.repeat(70000) })) + assert.deepEqual(await readTree(sample.ids), before) + assert.deepEqual(await auditCounts(sample.root.id), [0, 0, 0]) + }) + + await t.test('consistent trees keep their original pre-borrow snapshot and produce no correction audit', async () => { + const sample = await tree(2, true) + const result = await borrow(sample.root.id) + assert.ok(result.snapshotItems.every(item => item.status === '可用' && item.department_id === oldRootDepartment)) + assert.deepEqual(await auditCounts(sample.root.id), [1, 0, 0]) + assert.ok((await readTree(sample.ids)).every(row => row.department_id === targetDepartment)) + }) + } finally { + try { + await prisma.$transaction(async tx => { + await tx.disposalRecord.deleteMany({ where: { equipment_id: { in: equipmentIds } } }) + await tx.maintenanceRecord.deleteMany({ where: { equipment_id: { in: equipmentIds } } }) + await tx.borrowRecord.deleteMany({ where: { equipment_id: { in: equipmentIds } } }) + await tx.assetTransferRecord.deleteMany({ where: { root_equipment_id: { in: equipmentIds } } }) + await tx.operationLog.deleteMany({ where: { user_id: { in: userIds } } }) + 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.department.deleteMany({ where: { id: { in: departmentIds } } }) + await tx.user.deleteMany({ where: { id: { in: userIds } } }) + }, { timeout: 20000 }) + assert.deepEqual(await Promise.all([ + prisma.equipment.count({ where: { id: { in: equipmentIds } } }), + prisma.person.count({ where: { id: { in: personIds } } }), + prisma.department.count({ where: { id: { in: departmentIds } } }), + prisma.user.count({ where: { id: { in: userIds } } }), + prisma.borrowRecord.count({ where: { equipment_id: { in: equipmentIds } } }), + prisma.assetTransferRecord.count({ where: { root_equipment_id: { in: equipmentIds } } }), + prisma.operationLog.count({ where: { user_id: { in: userIds } } }) + ]), Array(7).fill(0), 'all exact-ID test rows must be removed') + } finally { await prisma.$disconnect() } + } +}) diff --git a/backend/src/services/assetMovementService.ts b/backend/src/services/assetMovementService.ts index 98590e9..1c5df29 100644 --- a/backend/src/services/assetMovementService.ts +++ b/backend/src/services/assetMovementService.ts @@ -1,5 +1,7 @@ import { Prisma } from '@prisma/client' import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue' +import { HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM } from '../utils/organization' +import { PERMISSIONS, roleHasPermission } from '../utils/permissions' type TransactionClient = Prisma.TransactionClient @@ -294,6 +296,7 @@ export const borrowAssetTree = async ( expectedReturnTime?: Date | null isLongTerm?: boolean notes?: string | null + operatedBy?: number } ) => { const items = await loadAndLockAssetTree(client, input.rootEquipmentId) @@ -323,11 +326,6 @@ export const borrowAssetTree = async ( const inconsistentDepartment = items.slice(1).find( (item) => item.department_id !== root.department_id ) - if (inconsistentDepartment) { - throw new AssetTreeConflictError( - `配件 ${inconsistentDepartment.equipment_code} 与主资产部门不一致,请先核查` - ) - } const equipmentIds = items.map((item) => item.id) const activeBorrowRows = await client.$queryRaw>(Prisma.sql` @@ -372,7 +370,48 @@ export const borrowAssetTree = async ( throw new AssetTreeConflictError('资产树存在进行中的报废申请,不能借用') } - const targetDepartmentId = input.person.main_department_id ?? root.department_id + let borrowPerson = input.person + let targetDepartmentId = input.person.main_department_id ?? root.department_id + let correctionOperator: { id: number; username: string } | undefined + let correctionDepartmentName: string | undefined + if (inconsistentDepartment) { + // Only an otherwise wholly idle tree is eligible. Never rewrite the + // department of stock that still belongs to a different active borrower. + if (root.remaining_quantity !== root.total_quantity || activeBorrowRows.length !== 0) { + throw new AssetTreeConflictError('部门不一致的资产树仍有借用占用,不能自动纠正,请先核查') + } + 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('使用者已失效、缺少主部门或部门已变化,请刷新人员后重试') + } + const departments = await client.$queryRaw>( + Prisma.sql`SELECT id, name, status, source_system FROM department WHERE id = ${person.main_department_id} FOR UPDATE` + ) + const department = departments[0] + if (!department || department.status !== 'active' || department.source_system !== HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM) { + throw new AssetTreeConflictError('使用者主部门不可用,请先核查人员部门') + } + const operators = await client.$queryRaw>( + Prisma.sql`SELECT id, username, status, role FROM user WHERE id = ${input.operatedBy} FOR UPDATE` + ) + if (!operators[0]) throw new AssetTreeConflictError('未获取到有效操作人,不能纠正资产部门') + if (operators[0].status !== 'active' || !roleHasPermission(operators[0].role, PERMISSIONS.ASSETS_BORROW)) { + throw new AssetTreeConflictError('操作人已停用或借出权限已变化,请重新登录后重试') + } + correctionOperator = operators[0] + correctionDepartmentName = department.name + borrowPerson = person + targetDepartmentId = department.id + } const nextRemainingQuantity = root.remaining_quantity - 1 const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出' const rootValues = calculateEquipmentValues({ @@ -441,15 +480,19 @@ export const borrowAssetTree = async ( } } - const snapshotItems = buildRecursiveBorrowSnapshot(items) + // The snapshot remains the available, pre-borrow configuration. On a + // correction, obsolete departments are audit history, not a return target. + const snapshotItems = buildRecursiveBorrowSnapshot(inconsistentDepartment + ? items.map((item) => ({ ...item, department_id: targetDepartmentId })) + : items) const record = await client.borrowRecord.create({ data: { equipment_id: root.id, equipment_code: root.equipment_code, - person_id: input.person.id, - person_name: input.person.real_name, - person_nickname: input.person.nickname, - person_identifier: input.person.job_number || null, + person_id: borrowPerson.id, + person_name: borrowPerson.real_name, + person_nickname: borrowPerson.nickname, + person_identifier: borrowPerson.job_number || null, expected_return_time: input.expectedReturnTime ?? null, is_long_term: Boolean(input.isLongTerm), notes: input.notes?.trim() || null, @@ -458,6 +501,27 @@ export const borrowAssetTree = async ( } }) + if (correctionOperator) { + const reason = `借出时纠正历史部门不一致;借用单 ${record.id};使用者 ${borrowPerson.id}(${borrowPerson.real_name})` + const movement = await createAssetMovementRecord(client, { + items, actionType: 'transfer', toDepartmentId: targetDepartmentId, + toDepartmentName: correctionDepartmentName, operatedBy: correctionOperator.id, reason + }) + // Audit must commit with the inventory and borrow record, or all roll back. + await client.operationLog.create({ data: { + user_id: correctionOperator.id, username: correctionOperator.username, + operation_type: '借出部门纠正', equipment_id: root.id, equipment_code: root.equipment_code, + operation_details: JSON.stringify({ + reason, borrow_record_id: record.id, movement_id: movement.id, + person_id: borrowPerson.id, operated_by: correctionOperator.id, + assets: items.map((item) => ({ + id: item.id, equipment_code: item.equipment_code, parent_id: item.parent_id, + from_department_id: item.department_id, to_department_id: targetDepartmentId + })) + }) + } }) + } + return { record, items,