GuDingZiChan/backend/src/controllers/disposalController.ts

724 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { 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 {
createBusinessNotification,
escapeNotificationMarkdownInline,
findActiveDingTalkUsersByIds,
findActiveDingTalkUsersByRoles,
findActiveStationUsersByIds,
findActiveStationUsersByRoles,
formatBeijingDateTime,
getCurrentDingTalkRecipientRollout,
mergeRecipients
} from '../services/businessNotificationService'
class DisposalConflictError extends Error {
status = 409
}
const blockedStatuses = new Set(['已借出', '维护中', '待报废', '已报废'])
const disposalInclude = {
equipment: true,
applier: { select: { id: true, name: true, username: true } },
approver: { select: { id: true, name: true, username: true } },
completer: { select: { id: true, name: true, username: true } },
items: {
orderBy: { id: 'asc' as const },
include: {
equipment: {
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
status: true,
parent_id: true,
storage_location_id: true,
department_id: true
}
}
}
},
actions: {
orderBy: [{ created_at: 'asc' as const }, { id: 'asc' as const }],
include: { operator: { select: { id: true, name: true, username: true } } }
}
}
const updateEquipmentLifecycle = async (
tx: Prisma.TransactionClient,
equipment: {
id: number
unit_price: Prisma.Decimal
total_quantity: number
remaining_quantity: number
status: string
parent_id: number | null
department_id: number | null
mount_status: string
updated_at: Date
},
targetStatus: string,
expectedStatus: string,
expectedParentId: number | null = equipment.parent_id
) => {
const remainingQuantity = targetStatus === '已借出' ? 0 : equipment.remaining_quantity
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: equipment.total_quantity,
remainingQuantity,
status: targetStatus
})
const updated = await tx.equipment.updateMany({
where: {
id: equipment.id,
status: expectedStatus,
parent_id: expectedParentId,
department_id: equipment.department_id,
mount_status: equipment.mount_status,
remaining_quantity: equipment.remaining_quantity,
updated_at: equipment.updated_at
},
data: {
status: targetStatus,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
})
if (updated.count !== 1) {
throw new DisposalConflictError('资产状态或挂载关系已变化,请刷新后重试')
}
}
const getSubmissionRecipients = async (tx: Prisma.TransactionClient) => {
const rollout = getCurrentDingTalkRecipientRollout()
const [stationAdministrative, dingTalkAdministrative] = await Promise.all([
findActiveStationUsersByRoles(tx, ['administrative']),
findActiveDingTalkUsersByRoles(tx, ['administrative'], rollout)
])
const stationRecipients = stationAdministrative.length > 0
? stationAdministrative
: await findActiveStationUsersByRoles(tx, ['admin'])
const dingTalkRecipients = dingTalkAdministrative.length > 0
? dingTalkAdministrative
: await findActiveDingTalkUsersByRoles(tx, ['admin'], rollout)
return { stationRecipients, dingTalkRecipients }
}
const getResultRecipients = async (tx: Prisma.TransactionClient, applierId: number) => {
const rollout = getCurrentDingTalkRecipientRollout()
const [stationApplier, stationAdministrative, dingTalkApplier, dingTalkAdministrative] = await Promise.all([
findActiveStationUsersByIds(tx, [applierId]),
findActiveStationUsersByRoles(tx, ['administrative']),
findActiveDingTalkUsersByIds(tx, [applierId], rollout),
findActiveDingTalkUsersByRoles(tx, ['administrative'], rollout)
])
let stationRecipients = mergeRecipients(stationApplier, stationAdministrative)
if (stationApplier.length === 0 || stationAdministrative.length === 0) {
stationRecipients = mergeRecipients(
stationRecipients,
await findActiveStationUsersByRoles(tx, ['admin'])
)
}
let dingTalkRecipients = mergeRecipients(dingTalkApplier, dingTalkAdministrative)
if (dingTalkApplier.length === 0 || dingTalkAdministrative.length === 0) {
dingTalkRecipients = mergeRecipients(
dingTalkRecipients,
await findActiveDingTalkUsersByRoles(tx, ['admin'], rollout)
)
}
return { stationRecipients, dingTalkRecipients }
}
const assetNotificationName = (name: unknown, code: unknown) =>
`${escapeNotificationMarkdownInline(name, '资产')}(${escapeNotificationMarkdownInline(code, '无编码')})`
export const buildDisposalAppliedDingTalkContent = (input: {
appliedAt: Date
equipmentName: unknown
equipmentCode: unknown
applicantName: unknown
reason: unknown
}) => [
`**申请时间:** ${formatBeijingDateTime(input.appliedAt)}`,
`**资产:** ${assetNotificationName(input.equipmentName, input.equipmentCode)}`,
`**申请人:** ${escapeNotificationMarkdownInline(input.applicantName)}`,
`**报废原因:** ${escapeNotificationMarkdownInline(input.reason)}`,
'> 请核对资产及配件明细后处理。'
].join('\n\n')
export const buildDisposalApprovedDingTalkContent = (input: {
approvedAt: Date
equipmentName: unknown
equipmentCode: unknown
approverName: unknown
}) => [
`**审批时间:** ${formatBeijingDateTime(input.approvedAt)}`,
`**资产:** ${assetNotificationName(input.equipmentName, input.equipmentCode)}`,
`**审批人:** ${escapeNotificationMarkdownInline(input.approverName)}`,
'**审批结果:** 已通过',
'> 报废单现已进入待处置阶段。'
].join('\n\n')
export const buildDisposalRejectedDingTalkContent = (input: {
rejectedAt: Date
equipmentName: unknown
equipmentCode: unknown
rejectorName: unknown
reason: unknown
}) => [
`**驳回时间:** ${formatBeijingDateTime(input.rejectedAt)}`,
`**资产:** ${assetNotificationName(input.equipmentName, input.equipmentCode)}`,
`**驳回人:** ${escapeNotificationMarkdownInline(input.rejectorName)}`,
`**驳回原因:** ${escapeNotificationMarkdownInline(input.reason)}`,
'> 资产状态已恢复为申请前状态。'
].join('\n\n')
export const buildDisposalCompletedDingTalkContent = (input: {
completedAt: Date
equipmentName: unknown
equipmentCode: unknown
completerName: unknown
notes: unknown
}) => [
`**完成时间:** ${formatBeijingDateTime(input.completedAt)}`,
`**资产:** ${assetNotificationName(input.equipmentName, input.equipmentCode)}`,
`**完成人:** ${escapeNotificationMarkdownInline(input.completerName)}`,
'**处置结果:** 已完成报废',
`**完成说明:** ${escapeNotificationMarkdownInline(input.notes, '无')}`,
'> 相关资产现已统一标记为已报废。'
].join('\n\n')
const getCurrentUser = (res: Response) => ({
id: Number(res.locals.user?.id),
role: String(res.locals.user?.role || '')
})
const withHistoricalDisposalNotes = <T extends {
status: string
notes?: string | null
completion_notes?: string | null
}>(record: T) => ({
...record,
historical_notes:
record.status === 'completed' && record.notes && !record.completion_notes
? record.notes
: null
})
export const createDisposal = async (req: Request, res: Response) => {
try {
const {
equipment_id,
item_ids,
disposal_reason,
disposal_method,
estimated_value,
notes
} = req.body as Record<string, any>
const userId = Number(res.locals.user?.id)
const rootId = Number(equipment_id)
if (!userId) return errorResponse(res, '用户未登录', 401)
if (!Number.isInteger(rootId) || rootId <= 0) {
return errorResponse(res, '请选择要报废的主资产', 422)
}
if (!disposal_reason?.trim() || !disposal_method?.trim()) {
return errorResponse(res, '报废原因和处置方式不能为空', 422)
}
const estimatedValue = Number(estimated_value ?? 0)
if (!Number.isFinite(estimatedValue) || estimatedValue < 0) {
return errorResponse(res, '残值估价必须是大于或等于0的有效数字', 422)
}
const createdId = await prisma.$transaction(async (tx) => {
const tree = await loadAndLockAssetTree(tx, rootId)
const root = tree[0]
if (root.parent_id) {
throw new Error('挂载中的子资产不能单独申请报废,请先卸载')
}
const requestedIds = Array.isArray(item_ids)
? [...new Set(item_ids.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
: [rootId]
if (!requestedIds.includes(rootId)) {
throw new Error('报废明细必须包含主资产')
}
const treeIds = new Set(tree.map((item) => item.id))
const foreignId = requestedIds.find((id) => !treeIds.has(id))
if (foreignId) {
throw new Error('报废明细包含不属于当前资产树的资产')
}
const omitted = tree.filter((item) => !requestedIds.includes(item.id))
if (omitted.length > 0) {
throw new DisposalConflictError(
`配件 ${omitted.map((item) => item.equipment_code).join('、')} 仍处于挂载状态;请先卸载未勾选配件`
)
}
const blocked = tree.find((item) => blockedStatuses.has(item.status))
if (blocked) {
const message = blocked.status === '维护中'
? '资产正在维修中,请先完成或取消维修'
: `资产 ${blocked.equipment_code} 当前为${blocked.status},不能申请报废`
throw new Error(message)
}
const ids = tree.map((item) => item.id)
const [activeBorrowCount, activeMaintenanceCount, activeDisposalCount] = await Promise.all([
tx.borrowRecord.count({ where: { equipment_id: { in: ids }, status: '已借出' } }),
tx.maintenanceRecord.count({ where: { equipment_id: { in: ids }, status: 'in_progress' } }),
tx.disposalItem.count({
where: {
equipment_id: { in: ids },
disposal: { status: { in: ['pending', 'approved'] } }
}
})
])
if (activeBorrowCount > 0) throw new Error('资产树存在未归还记录,不能申请报废')
if (activeMaintenanceCount > 0) throw new Error('资产树存在进行中的维修,请先完成或取消维修')
if (activeDisposalCount > 0) throw new Error('资产树已有进行中的报废申请')
const disposal = await tx.disposalRecord.create({
data: {
equipment_id: root.id,
equipment_code: root.equipment_code,
disposal_reason: disposal_reason.trim(),
disposal_method: disposal_method.trim(),
estimated_value: estimatedValue,
applied_by: userId,
pre_status: root.status,
notes: notes?.trim() || null,
items: {
create: tree.map((item) => {
const parent = item.parent_id
? tree.find((candidate) => candidate.id === item.parent_id)
: null
return {
equipment_id: item.id,
equipment_code: item.equipment_code,
equipment_name: item.equipment_name,
parent_equipment_id: item.parent_id,
parent_equipment_code: parent?.equipment_code || null,
pre_status: item.status,
snapshot_json: JSON.stringify({
equipment_code: item.equipment_code,
equipment_name: item.equipment_name,
parent_id: item.parent_id,
department_id: item.department_id,
storage_location_id: item.storage_location_id,
status: item.status
})
}
})
},
actions: {
create: {
action_type: 'applied',
operator_id: userId,
from_status: null,
to_status: 'pending',
detail: disposal_reason.trim()
}
}
}
})
for (const item of tree) {
await updateEquipmentLifecycle(tx, item, '待报废', item.status)
}
const recipients = await getSubmissionRecipients(tx)
await createBusinessNotification(tx, {
eventType: 'disposal_applied',
businessType: 'disposal',
businessId: disposal.id,
title: `待审批报废:${root.equipment_name}`,
content: `${res.locals.user?.name || res.locals.user?.username} 提交了 ${root.equipment_code} 的报废申请`,
level: 'warning',
actionUrl: `/workflows/disposals/${disposal.id}`,
stationRecipients: recipients.stationRecipients,
dingTalkRecipients: recipients.dingTalkRecipients,
metadata: { equipment_code: root.equipment_code },
dingTalk: {
title: '资产报废待审批',
content: buildDisposalAppliedDingTalkContent({
appliedAt: disposal.applied_at,
equipmentName: root.equipment_name,
equipmentCode: root.equipment_code,
applicantName: res.locals.user?.name || res.locals.user?.username,
reason: disposal_reason
}),
actionTitle: '立即审批',
messageVersion: 'v1'
}
})
return disposal.id
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
const disposal = await prisma.disposalRecord.findUnique({
where: { id: createdId },
include: disposalInclude
})
await logOperation(
userId,
res.locals.user?.username || res.locals.user?.name || 'unknown',
'报废申请',
`申请报废资产 ${disposal?.equipment_code},原因:${disposal_reason}`,
rootId,
disposal?.equipment_code,
req.ip
)
successResponse(res, disposal, '报废申请创建成功')
} catch (error: any) {
console.error('[创建报废申请失败]', error?.message || error)
errorResponse(res, error.message || '创建报废申请失败', error.status || 422)
}
}
export const getDisposalList = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 10, status, keyword, equipment_id, scope } = req.query
const currentUser = getCurrentUser(res)
const resolvedScope = String(scope || (currentUser.role === 'admin'
? 'all'
: currentUser.role === 'administrative'
? 'pending'
: 'mine'))
const validScopes = ['all', 'mine', 'pending', 'processed']
if (!validScopes.includes(resolvedScope)) {
return errorResponse(res, '报废列表范围无效', 422)
}
const scopeConditions: Prisma.DisposalRecordWhereInput[] = []
if (currentUser.role === 'it') {
scopeConditions.push({ applied_by: currentUser.id })
} else if (currentUser.role === 'administrative') {
if (resolvedScope === 'pending') {
scopeConditions.push({ status: 'pending' })
} else if (resolvedScope === 'processed') {
scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
} else {
return errorResponse(res, '行政仅可查看全部待审批和全部已处理报废流程', 403)
}
} else if (currentUser.role === 'admin') {
if (resolvedScope === 'mine') {
scopeConditions.push({ applied_by: currentUser.id })
} else if (resolvedScope === 'pending') {
scopeConditions.push({ status: 'pending' })
} else if (resolvedScope === 'processed') {
scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
}
} else {
return errorResponse(res, '无权查看报废流程', 403)
}
if (status) scopeConditions.push({ status: String(status) })
if (equipment_id) scopeConditions.push({ equipment_id: Number(equipment_id) })
if (keyword) {
const keywordWhere = [
{ equipment_code: { contains: String(keyword) } },
{ disposal_reason: { contains: String(keyword) } },
{ items: { some: { equipment_code: { contains: String(keyword) } } } }
]
scopeConditions.push({ OR: keywordWhere })
}
const where: Prisma.DisposalRecordWhereInput = scopeConditions.length > 0
? { AND: scopeConditions }
: {}
const skip = (Number(page) - 1) * Number(pageSize)
const [list, total] = await Promise.all([
prisma.disposalRecord.findMany({
where,
skip,
take: Number(pageSize),
orderBy: { created_at: 'desc' },
include: {
equipment: { select: { equipment_name: true, equipment_type: true, unit_price: true } },
applier: { select: { id: true, name: true, username: true } },
approver: { select: { id: true, name: true, username: true } },
completer: { select: { id: true, name: true, username: true } },
_count: { select: { items: true } }
}
}),
prisma.disposalRecord.count({ where })
])
successResponse(
res,
{ list: list.map(withHistoricalDisposalNotes), total, page: Number(page), pageSize: Number(pageSize) },
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取报废记录列表失败')
}
}
export const getDisposalDetail = async (req: Request, res: Response) => {
try {
const disposal = await prisma.disposalRecord.findUnique({
where: { id: Number(req.params.id) },
include: disposalInclude
})
if (!disposal) return errorResponse(res, '报废记录不存在', 404)
const currentUser = getCurrentUser(res)
if (currentUser.role === 'it' && disposal.applied_by !== currentUser.id) {
return errorResponse(res, '无权查看该报废申请', 403)
}
successResponse(res, withHistoricalDisposalNotes(disposal), '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取报废记录详情失败')
}
}
export const approveDisposal = async (req: Request, res: Response) => {
try {
const disposalId = Number(req.params.id)
const { approved, reject_reason } = req.body as { approved?: boolean; reject_reason?: string }
const userId = Number(res.locals.user?.id)
if (typeof approved !== 'boolean') {
return errorResponse(res, '审批结果必须明确为通过或驳回', 422)
}
if (!approved && !reject_reason?.trim()) {
return errorResponse(res, '驳回原因不能为空', 422)
}
const result = await prisma.$transaction(async (tx) => {
const disposal = await tx.disposalRecord.findUnique({
where: { id: disposalId },
include: { items: { include: { equipment: true } } }
})
if (!disposal) throw Object.assign(new Error('报废记录不存在'), { status: 404 })
const changedItem = disposal.items.find((item) =>
item.equipment.status !== '待报废' ||
item.equipment.parent_id !== item.parent_equipment_id
)
if (changedItem) {
throw new DisposalConflictError(
`资产 ${changedItem.equipment_code} 的状态或挂载关系已变化,请先核查`
)
}
const nextStatus = approved ? 'approved' : 'rejected'
const approvedAt = new Date()
const updated = await tx.disposalRecord.updateMany({
where: { id: disposalId, status: 'pending' },
data: {
status: nextStatus,
approved_by: userId,
approved_at: approvedAt,
reject_reason: approved ? null : reject_reason?.trim()
}
})
if (updated.count !== 1) {
throw new DisposalConflictError('该申请已被其他人处理,请刷新后查看')
}
if (!approved) {
for (const item of disposal.items) {
await updateEquipmentLifecycle(
tx,
item.equipment,
item.pre_status,
'待报废',
item.parent_equipment_id
)
}
}
await tx.disposalAction.create({
data: {
disposal_id: disposalId,
action_type: approved ? 'approved' : 'rejected',
operator_id: userId,
from_status: 'pending',
to_status: nextStatus,
detail: approved ? null : reject_reason?.trim()
}
})
const recipients = await getResultRecipients(tx, disposal.applied_by)
const rootItem = disposal.items.find((item) => item.equipment_id === disposal.equipment_id)
await createBusinessNotification(tx, {
eventType: approved ? 'disposal_approved' : 'disposal_rejected',
businessType: 'disposal',
businessId: disposal.id,
title: approved ? `报废已审批:${disposal.equipment_code}` : `报废已驳回:${disposal.equipment_code}`,
content: approved ? '报废申请已审批通过,等待完成处置' : `驳回原因:${reject_reason?.trim()}`,
level: approved ? 'success' : 'warning',
actionUrl: `/workflows/disposals/${disposal.id}`,
stationRecipients: recipients.stationRecipients,
dingTalkRecipients: recipients.dingTalkRecipients,
dingTalk: approved
? {
title: '资产报废审批已通过',
content: buildDisposalApprovedDingTalkContent({
approvedAt,
equipmentName: rootItem?.equipment.equipment_name,
equipmentCode: disposal.equipment_code,
approverName: res.locals.user?.name || res.locals.user?.username
}),
actionTitle: '查看审批结果',
messageVersion: 'v1'
}
: {
title: '资产报废申请已驳回',
content: buildDisposalRejectedDingTalkContent({
rejectedAt: approvedAt,
equipmentName: rootItem?.equipment.equipment_name,
equipmentCode: disposal.equipment_code,
rejectorName: res.locals.user?.name || res.locals.user?.username,
reason: reject_reason
}),
actionTitle: '查看申请',
messageVersion: 'v1'
}
})
return disposal
})
await logOperation(
userId,
res.locals.user?.username || res.locals.user?.name || 'unknown',
approved ? '报废审批通过' : '报废审批驳回',
`${approved ? '批准' : '驳回'}资产 ${result.equipment_code} 的报废申请`,
result.equipment_id,
result.equipment_code,
req.ip
)
successResponse(res, null, approved ? '审批通过' : '已驳回')
} catch (error: any) {
errorResponse(res, error.message || '审批失败', error.status || 500)
}
}
export const completeDisposal = async (req: Request, res: Response) => {
try {
const disposalId = Number(req.params.id)
const { actual_value, completion_notes, notes } = req.body as Record<string, any>
const userId = Number(res.locals.user?.id)
const actualValue = actual_value === undefined ? undefined : Number(actual_value)
if (actualValue !== undefined && (!Number.isFinite(actualValue) || actualValue < 0)) {
return errorResponse(res, '实际处置价值必须是大于或等于0的有效数字', 422)
}
const result = await prisma.$transaction(async (tx) => {
const disposal = await tx.disposalRecord.findUnique({
where: { id: disposalId },
include: { items: { include: { equipment: true } } }
})
if (!disposal) throw Object.assign(new Error('报废记录不存在'), { status: 404 })
const completedAt = new Date()
const updated = await tx.disposalRecord.updateMany({
where: { id: disposalId, status: 'approved' },
data: {
status: 'completed',
actual_value: actualValue !== undefined ? actualValue : disposal.estimated_value,
completed_at: completedAt,
completed_by: userId,
completion_notes: completion_notes?.trim() || notes?.trim() || null
}
})
if (updated.count !== 1) {
throw new DisposalConflictError('该申请状态已变化,请刷新后查看')
}
for (const item of disposal.items) {
await updateEquipmentLifecycle(
tx,
item.equipment,
'已报废',
'待报废',
item.parent_equipment_id
)
}
await tx.disposalAction.create({
data: {
disposal_id: disposalId,
action_type: 'completed',
operator_id: userId,
from_status: 'approved',
to_status: 'completed',
detail: completion_notes?.trim() || notes?.trim() || null
}
})
const recipients = await getResultRecipients(tx, disposal.applied_by)
const rootItem = disposal.items.find((item) => item.equipment_id === disposal.equipment_id)
await createBusinessNotification(tx, {
eventType: 'disposal_completed',
businessType: 'disposal',
businessId: disposal.id,
title: `报废处置已完成:${disposal.equipment_code}`,
content: completion_notes?.trim() || notes?.trim() || '资产已完成报废处置',
level: 'success',
actionUrl: `/workflows/disposals/${disposal.id}`,
stationRecipients: recipients.stationRecipients,
dingTalkRecipients: recipients.dingTalkRecipients,
dingTalk: {
title: '资产报废处置已完成',
content: buildDisposalCompletedDingTalkContent({
completedAt,
equipmentName: rootItem?.equipment.equipment_name,
equipmentCode: disposal.equipment_code,
completerName: res.locals.user?.name || res.locals.user?.username,
notes: completion_notes?.trim() || notes?.trim()
}),
actionTitle: '查看结果',
messageVersion: 'v1'
}
})
return disposal
})
await logOperation(
userId,
res.locals.user?.username || res.locals.user?.name || 'unknown',
'报废完成',
`完成资产 ${result.equipment_code} 的报废处置`,
result.equipment_id,
result.equipment_code,
req.ip
)
successResponse(res, null, '报废处置完成')
} catch (error: any) {
errorResponse(res, error.message || '完成报废处置失败', error.status || 500)
}
}
export const getEquipmentDisposalHistory = async (req: Request, res: Response) => {
try {
const equipmentId = Number(req.params.equipment_id)
const history = await prisma.disposalRecord.findMany({
where: {
OR: [
{ equipment_id: equipmentId },
{ items: { some: { equipment_id: equipmentId } } }
]
},
orderBy: { created_at: 'desc' },
include: {
applier: { select: { id: true, name: true } },
approver: { select: { id: true, name: true } },
completer: { select: { id: true, name: true } },
items: true,
actions: { orderBy: { created_at: 'asc' } }
}
})
successResponse(res, history, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取报废历史失败')
}
}