fix: reconcile historical asset departments during borrow

This commit is contained in:
WuYongJie 2026-10-28 17:21:33 +08:00
parent b6751bb8cb
commit ab63d25806
4 changed files with 358 additions and 17 deletions

View File

@ -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: '借出成功' })

View File

@ -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: '出库成功' })

View File

@ -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<string, any>) => {
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<string, any> = {}) => 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() }
}
})

View File

@ -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<Array<{ id: number; equipment_id: number }>>(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<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('使用者已失效、缺少主部门或部门已变化,请刷新人员后重试')
}
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`
)
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<Array<{ id: number; username: string; status: string; role: string }>>(
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,