import { Request, Response } from 'express' import { Prisma } from '@prisma/client' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { logOperation } from './operationLogController' import { calculateEquipmentValues } from '../utils/assetValue' import { loadAndLockAssetTree } from '../services/assetMovementService' import { buildMaintenanceSnapshot, validateMaintenanceSnapshot } from '../utils/maintenanceSnapshot' class MaintenanceConflictError extends Error { status = 409 } const blockedMaintenanceStatuses = new Set(['已借出', '维护中', '待报废', '已报废']) const MAINTENANCE_STATUSES = ['in_progress', 'completed', 'cancelled'] as const const parseListPositiveInteger = ( value: unknown, fieldName: string, fallback: number, max?: number ) => { if (value === undefined || value === null || value === '') { if (fallback > 0) return fallback throw Object.assign(new Error(`${fieldName}无效`), { status: 422 }) } if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+$/.test(String(value))) { throw Object.assign(new Error(`${fieldName}无效`), { status: 422 }) } const parsed = Number(value) if (!Number.isSafeInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) { throw Object.assign(new Error(`${fieldName}无效`), { status: 422 }) } return parsed } // 创建维修记录 export const createMaintenance = async (req: Request, res: Response) => { try { const { equipment_id, issue_description, repair_vendor, estimated_cost, expected_end_date } = req.body const userId = Number(res.locals.user?.id) const equipmentId = Number(equipment_id) if (!userId) { return errorResponse(res, '用户未登录', 401) } if (!Number.isInteger(equipmentId) || equipmentId <= 0) { return errorResponse(res, '资产ID无效', 422) } const result = await prisma.$transaction(async (tx) => { const tree = await loadAndLockAssetTree(tx, equipmentId) const equipment = tree[0] if (equipment.parent_id) { throw Object.assign(new Error('该子资产当前已挂载,请先卸载后再送修'), { status: 422 }) } const blocked = tree.find((item) => blockedMaintenanceStatuses.has(item.status)) if (blocked) { const message = blocked.status === '维护中' ? `资产 ${blocked.equipment_code} 已在维修中` : blocked.status === '已借出' ? `资产 ${blocked.equipment_code} 尚未归还,无法送修` : `资产 ${blocked.equipment_code} 已在报废流程中或已报废,无法送修` throw Object.assign(new Error(message), { status: 422 }) } const equipmentIds = tree.map((item) => item.id) const [activeBorrowCount, activeDisposalCount, activeMaintenanceCount] = await Promise.all([ tx.borrowRecord.count({ where: { equipment_id: { in: equipmentIds }, status: '已借出' } }), tx.disposalItem.count({ where: { equipment_id: { in: equipmentIds }, disposal: { status: { in: ['pending', 'approved'] } } } }), tx.maintenanceRecord.count({ where: { equipment_id: { in: equipmentIds }, status: 'in_progress' } }) ]) if (activeBorrowCount > 0) { throw Object.assign(new Error('资产树存在未归还记录,无法送修'), { status: 422 }) } if (activeDisposalCount > 0) { throw Object.assign(new Error('资产树存在进行中的报废申请,无法送修'), { status: 422 }) } if (activeMaintenanceCount > 0) { throw new MaintenanceConflictError('资产树已有进行中的维修记录,请刷新后查看') } if (tree.some((item) => !['可用', '已损坏'].includes(item.status))) { throw new MaintenanceConflictError('资产原状态无效,请人工核实后送修') } // All rows are locked by loadAndLockAssetTree; conditional writes still reject stale state. for (const item of tree) { const values = calculateEquipmentValues({ unitPrice: Number(item.unit_price), totalQuantity: item.total_quantity, remainingQuantity: item.remaining_quantity, status: '维护中' }) const acquired = await tx.equipment.updateMany({ where: { id: item.id, status: item.status, parent_id: item.parent_id, remaining_quantity: item.remaining_quantity, department_id: item.department_id, mount_status: item.mount_status, updated_at: item.updated_at }, data: { status: '维护中', ...values } }) if (acquired.count !== 1) { throw new MaintenanceConflictError('资产状态、库存或归属已变化,请刷新后重试') } } const created = await tx.maintenanceRecord.create({ data: { equipment_id: equipment.id, equipment_code: equipment.equipment_code, issue_description, repair_vendor, estimated_cost: Number(estimated_cost) || 0, expected_end_date: expected_end_date ? new Date(expected_end_date) : null, created_by: userId, original_tree_snapshot: buildMaintenanceSnapshot(tree) } }) return { maintenance: created, equipment } }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) // 记录操作日志 const username = res.locals.user?.username || res.locals.user?.name || 'unknown' await logOperation( userId, username, '送修', `资产 ${result.equipment.equipment_name}(${result.equipment.equipment_code}) 送修,故障描述:${issue_description}${repair_vendor ? `,维修商:${repair_vendor}` : ''}`, result.equipment.id, result.equipment.equipment_code, req.ip ) successResponse(res, result.maintenance, '维修记录创建成功') } catch (error: any) { console.error('[创建维修记录失败]', error) errorResponse(res, error.message || '创建维修记录失败', error.status || 500) } } // 获取维修记录列表 export const getMaintenanceList = async (req: Request, res: Response) => { try { const { page = 1, pageSize = 10, status, keyword, equipment_id } = req.query const currentUserRole = String(res.locals.user?.role || '') if (!['admin', 'it'].includes(currentUserRole)) { return errorResponse(res, '无权查看维修记录', 403) } const currentPage = parseListPositiveInteger(page, '页码', 1) const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 10, 100) const skip = (currentPage - 1) * currentPageSize const normalizedStatus = String(status || '').trim() if (normalizedStatus && !MAINTENANCE_STATUSES.includes(normalizedStatus as any)) { return errorResponse(res, '维修状态无效', 422) } const summaryWhere: any = {} if (equipment_id !== undefined && equipment_id !== '') { summaryWhere.equipment_id = parseListPositiveInteger(equipment_id, '资产ID', 0) } const normalizedKeyword = String(keyword || '').trim() if (normalizedKeyword) { summaryWhere.OR = [ { equipment_code: { contains: normalizedKeyword } }, { issue_description: { contains: normalizedKeyword } }, { repair_vendor: { contains: normalizedKeyword } } ] } const where = normalizedStatus ? { AND: [summaryWhere, { status: normalizedStatus }] } : summaryWhere const [list, total, statusGroups] = await Promise.all([ prisma.maintenanceRecord.findMany({ where, skip, take: currentPageSize, orderBy: { created_at: 'desc' }, include: { equipment: { select: { equipment_name: true, equipment_type: true } }, creator: { select: { id: true, name: true, username: true } } } }), prisma.maintenanceRecord.count({ where }), prisma.maintenanceRecord.groupBy({ by: ['status'], where: summaryWhere, _count: { _all: true } }) ]) const summary = { total: 0, in_progress: 0, completed: 0, cancelled: 0 } for (const group of statusGroups) { const count = group._count._all summary.total += count if (MAINTENANCE_STATUSES.includes(group.status as any)) { summary[group.status as (typeof MAINTENANCE_STATUSES)[number]] = count } } successResponse( res, { list, total, page: currentPage, pageSize: currentPageSize, summary }, '获取成功' ) } catch (error: any) { errorResponse(res, error.message || '获取维修记录列表失败', error.status || 500) } } // 获取维修记录详情 export const getMaintenanceDetail = async (req: Request, res: Response) => { try { const { id } = req.params const maintenance = await prisma.maintenanceRecord.findUnique({ where: { id: Number(id) }, include: { equipment: true, creator: { select: { id: true, name: true, username: true } } } }) if (!maintenance) { return errorResponse(res, '维修记录不存在', 404) } successResponse(res, maintenance, '获取成功') } catch (error: any) { errorResponse(res, error.message || '获取维修记录详情失败') } } // 更新维修记录 export const updateMaintenance = async (req: Request, res: Response) => { try { const { id } = req.params const { repair_vendor, estimated_cost, expected_end_date } = req.body const maintenanceId = Number(id) if (!Number.isInteger(maintenanceId) || maintenanceId <= 0) { return errorResponse(res, '维修记录ID无效', 422) } const result = await prisma.maintenanceRecord.updateMany({ where: { id: maintenanceId, status: 'in_progress' }, data: { repair_vendor, estimated_cost: estimated_cost !== undefined ? Number(estimated_cost) : undefined, expected_end_date: expected_end_date ? new Date(expected_end_date) : undefined } }) if (result.count !== 1) { const current = await prisma.maintenanceRecord.findUnique({ where: { id: maintenanceId }, select: { id: true, status: true } }) if (!current) { return errorResponse(res, '维修记录不存在', 404) } throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看') } const updated = await prisma.maintenanceRecord.findUnique({ where: { id: maintenanceId } }) successResponse(res, updated, '更新成功') } catch (error: any) { errorResponse(res, error.message || '更新维修记录失败', error.status || 500) } } // 完成维修 export const completeMaintenance = async (req: Request, res: Response) => { try { const { id } = req.params const { actual_cost, repair_result } = req.body const maintenanceId = parseListPositiveInteger(id, '维修记录ID', 0) const maintenance = await prisma.$transaction(async (tx) => { await tx.$queryRaw(Prisma.sql`SELECT id FROM maintenance_record WHERE id = ${maintenanceId} FOR UPDATE`) const currentMaintenance = await tx.maintenanceRecord.findUnique({ where: { id: maintenanceId } }) if (!currentMaintenance) { throw Object.assign(new Error('维修记录不存在'), { status: 404 }) } if (currentMaintenance.status !== 'in_progress') { throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看') } const tree = await loadAndLockAssetTree(tx, currentMaintenance.equipment_id) if (tree[0].parent_id !== null || tree.some((item) => item.status !== '维护中')) { throw new MaintenanceConflictError('资产树已不处于维修状态,不能覆盖当前状态') } // Legacy records can be completed after whole-tree checks, but cannot be // cancelled without original statuses. New records must match their tree. if (currentMaintenance.original_tree_snapshot) { validateMaintenanceSnapshot(currentMaintenance.original_tree_snapshot, tree, currentMaintenance.equipment_id) } const updatedMaintenance = await tx.maintenanceRecord.updateMany({ where: { id: maintenanceId, status: 'in_progress', updated_at: currentMaintenance.updated_at }, data: { status: 'completed', actual_cost: actual_cost !== undefined ? Number(actual_cost) : currentMaintenance.estimated_cost, actual_end_date: new Date(), repair_result } }) if (updatedMaintenance.count !== 1) { throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看') } for (const item of tree) { const values = calculateEquipmentValues({ unitPrice: Number(item.unit_price), totalQuantity: item.total_quantity, remainingQuantity: item.remaining_quantity, status: '可用' }) const updated = await tx.equipment.updateMany({ where: { id: item.id, status: '维护中', parent_id: item.parent_id, total_quantity: item.total_quantity, remaining_quantity: item.remaining_quantity, department_id: item.department_id, mount_status: item.mount_status, unit_price: item.unit_price, updated_at: item.updated_at }, data: { status: '可用', ...values } }) if (updated.count !== 1) { throw new MaintenanceConflictError('资产状态、库存或挂载关系已变化,不能覆盖当前状态') } } return currentMaintenance }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) // 记录操作日志 const userId = res.locals.user?.id const username = res.locals.user?.username || res.locals.user?.name || 'unknown' await logOperation( userId, username, '维修完成', `资产 (${maintenance.equipment_code}) 维修完成,实际费用:¥${actual_cost !== undefined ? actual_cost : maintenance.estimated_cost}${repair_result ? `,维修结果:${repair_result}` : ''}`, maintenance.equipment_id, maintenance.equipment_code, req.ip ) successResponse(res, null, '维修完成') } catch (error: any) { errorResponse(res, error.message || '完成维修失败', error.status || 500) } } // 取消维修 export const cancelMaintenance = async (req: Request, res: Response) => { try { const { id } = req.params const { reason } = req.body const maintenanceId = parseListPositiveInteger(id, '维修记录ID', 0) const maintenance = await prisma.$transaction(async (tx) => { // Completion acquires the maintenance row before equipment writes. Use // the same order to prevent completion/cancellation from deadlocking. await tx.$queryRaw(Prisma.sql`SELECT id FROM maintenance_record WHERE id = ${maintenanceId} FOR UPDATE`) const currentMaintenance = await tx.maintenanceRecord.findUnique({ where: { id: maintenanceId } }) if (!currentMaintenance) { throw Object.assign(new Error('维修记录不存在'), { status: 404 }) } if (currentMaintenance.status !== 'in_progress') { throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看') } const tree = await loadAndLockAssetTree(tx, currentMaintenance.equipment_id) const snapshot = validateMaintenanceSnapshot( currentMaintenance.original_tree_snapshot, tree, currentMaintenance.equipment_id ) const updatedMaintenance = await tx.maintenanceRecord.updateMany({ where: { id: maintenanceId, status: 'in_progress', updated_at: currentMaintenance.updated_at }, data: { status: 'cancelled', repair_result: reason || '已取消' } }) if (updatedMaintenance.count !== 1) { throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看') } for (const item of tree) { const previous = snapshot.find((entry) => entry.id === item.id)! const values = calculateEquipmentValues({ unitPrice: Number(item.unit_price), totalQuantity: item.total_quantity, remainingQuantity: previous.remaining_quantity, status: previous.status }) const restored = await tx.equipment.updateMany({ where: { id: item.id, status: '维护中', parent_id: previous.parent_id, total_quantity: item.total_quantity, remaining_quantity: item.remaining_quantity, department_id: item.department_id, mount_status: item.mount_status, updated_at: item.updated_at }, data: { status: previous.status, remaining_quantity: previous.remaining_quantity, ...values } }) if (restored.count !== 1) { throw new MaintenanceConflictError('资产已不处于原维修状态,不能覆盖当前状态') } } return currentMaintenance }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) // 记录操作日志 const userId = res.locals.user?.id const username = res.locals.user?.username || res.locals.user?.name || 'unknown' await logOperation( userId, username, '维修取消', `取消资产 (${maintenance.equipment_code}) 的维修${reason ? `,原因:${reason}` : ''}`, maintenance.equipment_id, maintenance.equipment_code, req.ip ) successResponse(res, null, '维修已取消') } catch (error: any) { errorResponse(res, error.message || '取消维修失败', error.status || 500) } } // 获取资产的维修历史 export const getEquipmentMaintenanceHistory = async (req: Request, res: Response) => { try { const { equipment_id } = req.params const history = await prisma.maintenanceRecord.findMany({ where: { equipment_id: Number(equipment_id) }, orderBy: { created_at: 'desc' }, include: { creator: { select: { id: true, name: true } } } }) // 计算总维修费用 const totalCost = history.reduce((sum, record) => { return sum + Number(record.actual_cost || record.estimated_cost || 0) }, 0) successResponse(res, { history, totalCost }, '获取成功') } catch (error: any) { errorResponse(res, error.message || '获取维修历史失败') } }