474 lines
18 KiB
TypeScript
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 || '获取维修历史失败')
|
|
}
|
|
}
|