fix: reconcile historical asset departments during borrow
This commit is contained in:
parent
b6751bb8cb
commit
ab63d25806
@ -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: '借出成功' })
|
||||
|
||||
@ -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: '出库成功' })
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
})
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user