724 lines
27 KiB
TypeScript
724 lines
27 KiB
TypeScript
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 || '获取报废历史失败')
|
||
}
|
||
}
|