diff --git a/backend/src/controllers/__tests__/borrowController.return-concurrency.test.ts b/backend/src/controllers/__tests__/borrowController.return-concurrency.test.ts index f660f58..5c8dcda 100644 --- a/backend/src/controllers/__tests__/borrowController.return-concurrency.test.ts +++ b/backend/src/controllers/__tests__/borrowController.return-concurrency.test.ts @@ -2,7 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import prisma from '../../utils/prisma' -import { returnEquipment } from '../borrowController' +import { returnEquipment, returnEquipmentBeforeDisposal } from '../borrowController' const now = new Date('2026-07-14T06:00:00.000Z') const root = { @@ -188,3 +188,316 @@ test('单笔归还接口拒绝 keep、set 或单独 department_id,且不访问 ;(prisma.equipment.findUnique as any) = originalEquipmentFindUnique } }) + +test('行政可在报废前将唯一未归还资产快捷入库,并保留正式流转记录', async () => { + const originalTransaction = prisma.$transaction + const borrowedRoot = { + ...root, + equipment_name: '待报废显示器', + status: '已借出', + total_quantity: 1, + remaining_quantity: 0 + } + const borrowRecord = { + id: 601, + equipment_id: borrowedRoot.id, + person_id: 15, + status: '已借出', + children_snapshot: null, + updated_at: now + } + const rootUpdates: any[] = [] + const movementReasons: string[] = [] + + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRaw: async (query: any) => { + const sql = Array.isArray(query.sql) ? query.sql.join(' ') : String(query.sql) + if (sql.includes('FROM borrow_record')) return [borrowRecord] + if (sql.startsWith('SELECT id FROM equipment')) return [] + if (sql.includes('WHERE equipment.id =')) return [borrowedRoot] + if (sql.includes('WHERE equipment.parent_id IN')) return [] + return [] + }, + equipment: { + findUnique: async () => borrowedRoot, + findMany: async () => [], + updateMany: async (args: any) => { + rootUpdates.push(args) + return { count: 1 } + }, + update: async () => borrowedRoot + }, + borrowRecord: { + updateMany: async (args: any) => { + if (args.where.id !== borrowRecord.id || borrowRecord.status !== args.where.status) { + return { count: 0 } + } + borrowRecord.status = args.data.status + return { count: 1 } + } + }, + department: { findUnique: async () => ({ name: '领用部门' }) }, + assetTransferRecord: { + create: async (args: any) => { + movementReasons.push(args.data.reason) + return { id: 2, items: [] } + } + } + }) + + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 8, role: 'administrative', name: '行政人员' } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + + try { + await returnEquipmentBeforeDisposal( + { + params: { equipment_id: String(borrowedRoot.id) }, + body: { return_condition: '损坏' } + } as any, + res as any + ) + + assert.equal(state.statusCode, 200) + assert.equal(state.body.message, '资产已入库,可以继续申请报废') + assert.equal(state.body.data.return_condition, '损坏') + assert.equal(borrowRecord.status, '已归还') + assert.equal(rootUpdates.at(-1)?.data.status, '可用') + assert.equal(rootUpdates.at(-1)?.data.department_id, null) + assert.deepEqual(movementReasons, ['报废前快捷入库']) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('报废前快捷入库遇到多条未归还记录时拒绝自动处理', async () => { + const originalTransaction = prisma.$transaction + const borrowedRoot = { ...root, status: '已借出', total_quantity: 1, remaining_quantity: 0 } + let transactionCount = 0 + let businessWriteCount = 0 + const activeRecords = [ + { id: 701, equipment_id: root.id, person_id: 1, status: '已借出', children_snapshot: null, updated_at: now }, + { id: 702, equipment_id: root.id, person_id: 2, status: '已借出', children_snapshot: null, updated_at: now } + ] + + ;(prisma.$transaction as any) = async (callback: any) => { + transactionCount += 1 + return callback({ + $queryRaw: async (query: any) => { + const sql = Array.isArray(query.sql) ? query.sql.join(' ') : String(query.sql) + if (sql.includes('FROM borrow_record')) return activeRecords + if (sql.startsWith('SELECT id FROM equipment')) return [] + if (sql.includes('WHERE equipment.id =')) return [borrowedRoot] + if (sql.includes('WHERE equipment.parent_id IN')) return [] + return [] + }, + equipment: { + findUnique: async () => borrowedRoot, + findMany: async () => [], + updateMany: async () => { businessWriteCount += 1; return { count: 1 } }, + update: async () => { businessWriteCount += 1; return borrowedRoot } + }, + borrowRecord: { + updateMany: async () => { businessWriteCount += 1; return { count: 1 } } + }, + department: { findUnique: async () => ({ name: '领用部门' }) }, + assetTransferRecord: { + create: async () => { businessWriteCount += 1; return { id: 2, items: [] } } + } + }) + } + + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 8, role: 'administrative' } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + + try { + await returnEquipmentBeforeDisposal( + { params: { equipment_id: String(root.id) }, body: {} } as any, + res as any + ) + + assert.equal(state.statusCode, 422) + assert.match(state.body.message, /多条未归还记录/) + assert.equal(transactionCount, 1) + assert.equal(businessWriteCount, 0) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('报废前快捷入库拒绝未收回或多数量资产,且不会产生业务写入', async () => { + const originalTransaction = prisma.$transaction + let transactionCount = 0 + let businessWriteCount = 0 + const multiQuantityRoot = { + ...root, + status: '已借出', + total_quantity: 2, + remaining_quantity: 1 + } + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 8, role: 'administrative' } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + + ;(prisma.$transaction as any) = async (callback: any) => { + transactionCount += 1 + return callback({ + $queryRaw: async (query: any) => { + const sql = Array.isArray(query.sql) ? query.sql.join(' ') : String(query.sql) + if (sql.startsWith('SELECT id FROM equipment')) return [] + if (sql.includes('WHERE equipment.id =')) return [multiQuantityRoot] + if (sql.includes('WHERE equipment.parent_id IN')) return [] + return [] + }, + equipment: { + findUnique: async () => multiQuantityRoot, + findMany: async () => [], + updateMany: async () => { businessWriteCount += 1; return { count: 1 } }, + update: async () => { businessWriteCount += 1; return multiQuantityRoot } + }, + borrowRecord: { updateMany: async () => { businessWriteCount += 1; return { count: 1 } } }, + department: { findUnique: async () => ({ name: '领用部门' }) }, + assetTransferRecord: { + create: async () => { businessWriteCount += 1; return { id: 2, items: [] } } + } + }) + } + + try { + await returnEquipmentBeforeDisposal( + { params: { equipment_id: String(root.id) }, body: { return_condition: '丢失' } } as any, + res as any + ) + assert.equal(state.statusCode, 422) + assert.match(state.body.message, /正常或损坏资产/) + + state.statusCode = 200 + state.body = null + + await returnEquipmentBeforeDisposal( + { params: { equipment_id: String(root.id) }, body: { return_condition: '正常' } } as any, + res as any + ) + assert.equal(state.statusCode, 422) + assert.match(state.body.message, /仅支持单件资产/) + assert.equal(transactionCount, 1) + assert.equal(businessWriteCount, 0) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('借出时无配件但当前新增配件时进入版本确认,配件再变后旧确认不会入库', async () => { + const originalTransaction = prisma.$transaction + const borrowedRoot = { ...root, status: '已借出', total_quantity: 1, remaining_quantity: 0 } + let currentChild = { ...child, id: 802, equipment_code: 'JS-RAM-0802', parent_id: borrowedRoot.id } + const borrowRecord = { + id: 803, + equipment_id: borrowedRoot.id, + person_id: 15, + children_snapshot: null, + updated_at: now + } + let businessWriteCount = 0 + + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRaw: async (query: any) => { + const sql = Array.isArray(query.sql) ? query.sql.join(' ') : String(query.sql) + if (sql.includes('FROM borrow_record')) return [borrowRecord] + if (sql.startsWith('SELECT id FROM equipment')) return [] + if (sql.includes('WHERE equipment.id =')) return [borrowedRoot] + if (sql.includes('WHERE equipment.parent_id IN')) { + return query.values.includes(borrowedRoot.id) ? [currentChild] : [] + } + return [] + }, + equipment: { + findUnique: async () => borrowedRoot, + findMany: async (args: any) => { + const parentIds = args.where.parent_id.in as number[] + return parentIds.includes(borrowedRoot.id) ? [currentChild] : [] + }, + updateMany: async () => { businessWriteCount += 1; return { count: 1 } }, + update: async () => { businessWriteCount += 1; return currentChild } + }, + borrowRecord: { updateMany: async () => { businessWriteCount += 1; return { count: 1 } } }, + department: { findUnique: async () => ({ name: '领用部门' }) }, + assetTransferRecord: { + create: async () => { businessWriteCount += 1; return { id: 2, items: [] } } + } + }) + + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 8, role: 'administrative' } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + + try { + await returnEquipmentBeforeDisposal( + { params: { equipment_id: String(root.id) }, body: { return_condition: '正常' } } as any, + res as any + ) + assert.equal(state.statusCode, 200) + assert.equal(state.body.data.need_confirm, true) + const firstToken = state.body.data.confirmation_token + assert.match(firstToken, /^[a-f0-9]{64}$/) + assert.equal(businessWriteCount, 0) + + currentChild = { + ...currentChild, + id: 804, + equipment_code: 'JS-RAM-0804', + updated_at: new Date(now.getTime() + 1000) + } + state.body = null + + await returnEquipmentBeforeDisposal( + { + params: { equipment_id: String(root.id) }, + body: { return_condition: '正常', confirmation_token: firstToken } + } as any, + res as any + ) + assert.equal(state.body.data.need_confirm, true) + assert.notEqual(state.body.data.confirmation_token, firstToken) + assert.equal(businessWriteCount, 0) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) diff --git a/backend/src/controllers/borrowController.ts b/backend/src/controllers/borrowController.ts index dfe58f6..5219743 100644 --- a/backend/src/controllers/borrowController.ts +++ b/backend/src/controllers/borrowController.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express' import { Prisma } from '@prisma/client' +import { createHash } from 'crypto' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { @@ -15,8 +16,10 @@ import { logOperation } from './operationLogController' import { buildRecursiveBorrowSnapshot, borrowAssetTree, + createAssetMovementRecord, finalizeBorrowAnomalyReturnState, finalizeNormalReturnState, + loadAndLockAssetTree, loadAssetTree, registerBorrowAnomalyAssetTree, recordReturnToStorage @@ -338,6 +341,165 @@ const returnEquipmentInTransaction = async ( }) } +type DisposalReturnBorrowRecord = { + id: number + equipment_id: number + person_id: number + children_snapshot: string | null + updated_at: Date +} + +const getLockedDisposalChildrenDiff = ( + items: Awaited>, + borrowRecord: DisposalReturnBorrowRecord +) => { + const currentChildren = buildRecursiveBorrowSnapshot(items) + let borrowedChildren: Array<{ + id: number + parent_id?: number | null + parent_equipment_id?: number | null + equipment_code?: string + equipment_name?: string + }> = [] + if (borrowRecord.children_snapshot) { + try { + const parsed = JSON.parse(borrowRecord.children_snapshot) + if (!Array.isArray(parsed)) throw new Error('配件快照格式错误') + borrowedChildren = parsed + } catch { + throw new Error('借用记录的配件快照无效,无法安全快捷入库,请先使用正式归还流程') + } + } + + const borrowedById = new Map(borrowedChildren.map((child) => [Number(child.id), child])) + const currentById = new Map(currentChildren.map((child) => [child.id, child])) + const getParentId = (child: { parent_id?: number | null; parent_equipment_id?: number | null }) => + child.parent_id ?? child.parent_equipment_id ?? null + const samePlacement = ( + left: { parent_id?: number | null; parent_equipment_id?: number | null }, + right: { parent_id?: number | null; parent_equipment_id?: number | null } + ) => getParentId(left) === getParentId(right) + + const removed = borrowedChildren.filter((child) => { + const current = currentById.get(Number(child.id)) + return !current || !samePlacement(child, current) + }) + const added = currentChildren.filter((child) => { + const borrowed = borrowedById.get(child.id) + return !borrowed || !samePlacement(borrowed, child) + }) + + return removed.length === 0 && added.length === 0 ? null : { removed, added } +} + +const buildDisposalReturnConfirmationToken = ( + items: Awaited>, + borrowRecord: DisposalReturnBorrowRecord +) => createHash('sha256').update(JSON.stringify({ + borrow_record: { + id: borrowRecord.id, + equipment_id: borrowRecord.equipment_id, + updated_at: borrowRecord.updated_at.getTime(), + children_snapshot: borrowRecord.children_snapshot + }, + asset_tree: items.map((item) => ({ + id: item.id, + parent_id: item.parent_id, + department_id: item.department_id, + status: item.status, + mount_status: item.mount_status, + total_quantity: item.total_quantity, + remaining_quantity: item.remaining_quantity, + unit_price: item.unit_price.toString(), + updated_at: item.updated_at.getTime() + })) +})).digest('hex') + +const returnEquipmentBeforeDisposalInTransaction = async (input: { + equipmentId: number + returnCondition: string + confirmationToken?: string + operatedBy: number +}) => prisma.$transaction(async (tx) => { + const items = await loadAndLockAssetTree(tx, input.equipmentId) + const root = items[0] + if (root.parent_id !== null) { + throw new Error('挂载中的子资产不能单独入库或申请报废,请先卸载') + } + if (root.status !== '已借出') { + throw new Error(`资产当前为${root.status},无需执行报废前入库`) + } + if (root.total_quantity !== 1 || root.remaining_quantity !== 0) { + throw new Error('快捷入库仅支持单件资产;该资产存在多数量库存,请先逐笔归还后再申请报废') + } + + const activeBorrowRecords = await tx.$queryRaw(Prisma.sql` + SELECT id, equipment_id, person_id, children_snapshot, updated_at + FROM borrow_record + WHERE equipment_id = ${root.id} AND status = ${'已借出'} + ORDER BY id + FOR UPDATE + `) + if (activeBorrowRecords.length === 0) { + throw new Error('资产状态为已借出,但没有对应的未归还记录,请先处理借用异常') + } + if (activeBorrowRecords.length > 1) { + throw new Error('该资产存在多条未归还记录,请先逐笔归还后再申请报废') + } + + const borrowRecord = activeBorrowRecords[0] + const childrenDiff = getLockedDisposalChildrenDiff(items, borrowRecord) + const confirmationToken = buildDisposalReturnConfirmationToken(items, borrowRecord) + if (childrenDiff && input.confirmationToken !== confirmationToken) { + return { + need_confirm: true as const, + confirmation_token: confirmationToken, + children_diff: childrenDiff + } + } + + const notes = childrenDiff + ? `报废前快捷入库;配件变更:移除${childrenDiff.removed.length}个,新增${childrenDiff.added.length}个` + : '报废前快捷入库' + await createAssetMovementRecord(tx, { + items, + actionType: 'return', + toDepartmentId: null, + operatedBy: input.operatedBy, + reason: notes + }) + + const returnTime = new Date() + const updatedBorrowRecord = await tx.borrowRecord.updateMany({ + where: { + id: borrowRecord.id, + equipment_id: root.id, + status: '已借出' + }, + data: { + return_time: returnTime, + status: '已归还', + return_condition: input.returnCondition, + notes + } + }) + if (updatedBorrowRecord.count !== 1) { + throw new Error('该借用记录已归还,请刷新后重试') + } + + const returnState = await finalizeNormalReturnState(tx, root) + return { + need_confirm: false as const, + equipment_id: root.id, + equipment_code: root.equipment_code, + borrow_record_id: borrowRecord.id, + return_time: returnTime, + return_condition: input.returnCondition, + status: returnState.status, + children_diff: childrenDiff + } +}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + const resolveBorrowAnomalyReturnedInTransaction = async (equipment: any, operatedBy: number) => { return prisma.$transaction(async (tx) => { const { items } = await recordReturnToStorage(tx, { @@ -707,6 +869,53 @@ export const returnEquipment = async (req: Request, res: Response) => { } } +// 报废前快捷入库:仅供拥有报废申请权限的流程入口调用。 +// 不开放通用借出/归还权限,仍完整复用正式归还事务与资产流转记录。 +export const returnEquipmentBeforeDisposal = async (req: Request, res: Response) => { + try { + const equipmentId = parseNumber(req.params.equipment_id) + const returnCondition = String(req.body?.return_condition || '正常').trim() + const confirmationToken = typeof req.body?.confirmation_token === 'string' + ? req.body.confirmation_token.trim() + : '' + const userId = Number(res.locals.user?.id) + + if (!equipmentId || equipmentId <= 0) { + return errorResponse(res, '资产编号无效', 422) + } + if (!userId) { + return errorResponse(res, '用户未登录', 401) + } + if (!['正常', '损坏'].includes(returnCondition)) { + return errorResponse(res, '报废前快捷入库仅支持已收回的正常或损坏资产', 422) + } + if (confirmationToken && !/^[a-f0-9]{64}$/.test(confirmationToken)) { + return errorResponse(res, '配件确认凭据无效,请重新确认', 422) + } + + const result = await returnEquipmentBeforeDisposalInTransaction({ + equipmentId, + returnCondition, + confirmationToken, + operatedBy: userId + }) + if (result.need_confirm) { + return successResponse(res, { + need_confirm: true, + confirmation_token: result.confirmation_token, + children_diff: result.children_diff, + message: '当前挂载配件与借出时不一致,请确认后再入库' + }, '配件差异提醒') + } + + successResponse(res, result, '资产已入库,可以继续申请报废') + } catch (error: any) { + console.error('[报废前快捷入库失败]', error?.message || error) + const status = error.status || (error?.message === '资产不存在' ? 404 : 422) + errorResponse(res, error.message || '报废前快捷入库失败', status) + } +} + // 扫码入库 export const scanReturn = async (req: Request, res: Response) => { try { diff --git a/backend/src/routes/disposal.ts b/backend/src/routes/disposal.ts index 7310214..f28c384 100644 --- a/backend/src/routes/disposal.ts +++ b/backend/src/routes/disposal.ts @@ -7,6 +7,7 @@ import { completeDisposal, getEquipmentDisposalHistory } from '../controllers/disposalController' +import { returnEquipmentBeforeDisposal } from '../controllers/borrowController' import { protect, requirePermission } from '../middleware/auth' import { PERMISSIONS } from '../utils/permissions' @@ -15,6 +16,11 @@ const router = Router() router.use(protect()) router.post('/', requirePermission(PERMISSIONS.DISPOSALS_APPLY), createDisposal) router.get('/list', requirePermission(PERMISSIONS.NOTIFICATIONS_VIEW), getDisposalList) +router.post( + '/equipment/:equipment_id/return-to-storage', + requirePermission(PERMISSIONS.DISPOSALS_APPLY), + returnEquipmentBeforeDisposal +) router.get('/equipment/:equipment_id/history', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getEquipmentDisposalHistory) router.get('/:id', requirePermission(PERMISSIONS.NOTIFICATIONS_VIEW), getDisposalDetail) router.put('/:id/approve', requirePermission(PERMISSIONS.DISPOSALS_APPROVE), approveDisposal) diff --git a/frontend/src/api/disposal.ts b/frontend/src/api/disposal.ts index 4ae1f49..41ec2c6 100644 --- a/frontend/src/api/disposal.ts +++ b/frontend/src/api/disposal.ts @@ -75,6 +75,24 @@ export interface CreateDisposalParams { notes?: string } +export interface DisposalReturnChildrenDiff { + removed: Array<{ id?: number; equipment_code?: string; equipment_name?: string }> + added: Array<{ id?: number; equipment_code?: string; equipment_name?: string }> +} + +export interface DisposalReturnPreparationResult { + need_confirm?: boolean + message?: string + confirmation_token?: string + children_diff?: DisposalReturnChildrenDiff | null + equipment_id?: number + equipment_code?: string + borrow_record_id?: number + return_time?: string + return_condition?: string + status?: string +} + export interface DisposalSummary { total: number pending: number @@ -93,6 +111,16 @@ export interface DisposalListData { export const createDisposal = (data: CreateDisposalParams) => request.post('/disposal', data) +export const returnEquipmentBeforeDisposal = (equipmentId: number, data: { + return_condition: '正常' | '损坏' + confirmation_token?: string +}) => request + .post>( + `/disposal/equipment/${equipmentId}/return-to-storage`, + data + ) + .then((response) => response.data) + export const getDisposalList = (params: { page?: number pageSize?: number diff --git a/frontend/src/views/equipment/EquipmentForm.vue b/frontend/src/views/equipment/EquipmentForm.vue index 91c6f8e..bf27ce0 100644 --- a/frontend/src/views/equipment/EquipmentForm.vue +++ b/frontend/src/views/equipment/EquipmentForm.vue @@ -300,7 +300,7 @@

报废记录

- +
暂无报废记录
@@ -365,10 +365,89 @@
+ + + -
+