GuDingZiChan/backend/src/controllers/maintenanceController.ts

474 lines
18 KiB
TypeScript

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 || '获取维修历史失败')
}
}