GuDingZiChan/backend/src/controllers/disposalController.ts

895 lines
37 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, lockEquipmentRows } from '../services/assetMovementService'
import {
assertDisposalItemsConfirmed, DisposalConflictError, disposalTreeSelect, getDisposalBlockedReason,
getDisposalOccupancy, loadDisposalTrees, MAX_DISPOSAL_BATCH_ROOTS, type DisposalTreeItem
} from '../utils/disposalSubmission'
import {
createBusinessNotification,
escapeNotificationMarkdownInline,
findActiveDingTalkUsersByIds,
findActiveDingTalkUsersByRoles,
findActiveStationUsersByIds,
findActiveStationUsersByRoles,
formatBeijingDateTime,
getCurrentDingTalkRecipientRollout,
mergeRecipients
} from '../services/businessNotificationService'
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 excludeApplicant = <T extends { id: number }>(recipients: T[], applicantId: number) =>
recipients.filter((recipient) => recipient.id !== applicantId)
const getSubmissionRecipients = async (tx: Prisma.TransactionClient, applicantId: number) => {
const rollout = getCurrentDingTalkRecipientRollout()
const [stationAdministrative, dingTalkAdministrative] = await Promise.all([
findActiveStationUsersByRoles(tx, ['administrative']),
findActiveDingTalkUsersByRoles(tx, ['administrative'], rollout)
])
const eligibleStationAdministrative = excludeApplicant(stationAdministrative, applicantId)
const eligibleDingTalkAdministrative = excludeApplicant(dingTalkAdministrative, applicantId)
const stationRecipients = eligibleStationAdministrative.length > 0
? eligibleStationAdministrative
: await findActiveStationUsersByRoles(tx, ['admin'])
const dingTalkRecipients = eligibleDingTalkAdministrative.length > 0
? eligibleDingTalkAdministrative
: 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 getDisposalVisibility = (res: Response): Prisma.DisposalRecordWhereInput => {
const user = getCurrentUser(res)
if (!Number.isSafeInteger(user.id) || user.id <= 0) {
throw Object.assign(new Error('用户未登录'), { status: 401 })
}
if (user.role === 'admin' || user.role === 'administrative') return {}
if (user.role === 'it') return { applied_by: user.id }
throw Object.assign(new Error('无权查看报废流程'), { status: 403 })
}
const DISPOSAL_STATUSES = ['pending', 'approved', 'rejected', 'completed'] 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
}
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
})
const readDisposalSubmission = (body: Record<string, unknown>) => {
const reason = typeof body.disposal_reason === 'string' ? body.disposal_reason.trim() : ''
const method = typeof body.disposal_method === 'string' ? body.disposal_method.trim() : ''
if (!reason || !method) throw new Error('报废原因和处置方式不能为空')
if (method.length > 50) throw new Error('处置方式不能超过50个字符')
if (body.notes != null && typeof body.notes !== 'string') throw new Error('备注格式无效')
return { reason, method, notes: typeof body.notes === 'string' ? body.notes.trim() || null : null }
}
const writeDisposalSubmission = async (
tx: Prisma.TransactionClient, tree: DisposalTreeItem[], userId: number,
input: ReturnType<typeof readDisposalSubmission>, estimatedValue = 0
) => {
const root = tree[0]
const codeById = new Map(tree.map(item => [item.id, item.equipment_code]))
const disposal = await tx.disposalRecord.create({ data: {
equipment_id: root.id,
equipment_code: root.equipment_code,
disposal_reason: input.reason,
disposal_method: input.method,
estimated_value: estimatedValue,
applied_by: userId,
pre_status: root.status,
notes: input.notes,
items: { create: tree.map(item => ({
equipment_id: item.id, equipment_code: item.equipment_code, equipment_name: item.equipment_name,
parent_equipment_id: item.parent_id,
parent_equipment_code: item.parent_id === null ? null : codeById.get(item.parent_id) || 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: input.reason
} }
}, select: { id: true, applied_at: true } })
for (const item of tree) await updateEquipmentLifecycle(tx, item, '待报废', item.status)
return disposal
}
export const createDisposal = async (req: Request, res: Response) => {
try {
const {
equipment_id,
item_ids,
estimated_value
} = 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)
}
const input = readDisposalSubmission(req.body)
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]
assertDisposalItemsConfirmed(tree, requestedIds)
const blocked = getDisposalBlockedReason(tree)
if (blocked) throw new Error(blocked)
const occupancy = await getDisposalOccupancy(tx, tree.map(item => item.id))
const occupied = getDisposalBlockedReason(tree, occupancy)
if (occupied) throw new Error(occupied)
const disposal = await writeDisposalSubmission(tx, tree, userId, input, estimatedValue)
const recipients = await getSubmissionRecipients(tx, userId)
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: input.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},原因:${input.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)
}
}
const isEquipmentId = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0
export const previewBatchDisposal = async (req: Request, res: Response) => {
try {
if (!isEquipmentId(getCurrentUser(res).id)) return errorResponse(res, '用户未登录', 401)
const { equipment_ids = [], equipment_codes = [] } = req.body || {}
if (!Array.isArray(equipment_ids) || !equipment_ids.every(isEquipmentId) ||
!Array.isArray(equipment_codes) || !equipment_codes.every(code => typeof code === 'string' && code.trim())) {
return errorResponse(res, '请提供有效的资产ID或编码', 422)
}
const limitMessage = `每批须包含 1 至 ${MAX_DISPOSAL_BATCH_ROOTS} 个资产`
const codes: string[] = equipment_codes.map(code => code.trim())
const uniqueIds = [...new Set(equipment_ids)]
const uniqueCodes = new Map<string, string>()
for (const code of codes) {
const key = code.toLowerCase()
if (!uniqueCodes.has(key)) uniqueCodes.set(key, code)
}
if (uniqueIds.length + uniqueCodes.size === 0 ||
uniqueIds.length > MAX_DISPOSAL_BATCH_ROOTS || uniqueCodes.size > MAX_DISPOSAL_BATCH_ROOTS) {
return errorResponse(res, limitMessage, 422)
}
const roots = await prisma.equipment.findMany({
where: { OR: [{ id: { in: uniqueIds } }, { equipment_code: { in: [...uniqueCodes.values()] } }] }, select: disposalTreeSelect
})
const byId = new Map(roots.map(root => [root.id, root]))
const byCode = new Map(roots.map(root => [root.equipment_code.toLowerCase(), root]))
const inputs = [
...equipment_ids.map(id => ({ key: `id:${id}`, label: String(id), root: byId.get(id), isCode: false })),
...codes.map(code => ({ key: `code:${code.toLowerCase()}`, label: code, root: byCode.get(code.toLowerCase()), isCode: true }))
]
const seen = new Set<string>()
const duplicateCodes = new Map<string, string>()
const distinctInputs = inputs.filter(({ key, label, root, isCode }) => {
const identity = root ? `id:${root.id}` : key
if (seen.has(identity)) {
const code = root?.equipment_code || label
if ((root || isCode) && !duplicateCodes.has(code.toLowerCase())) duplicateCodes.set(code.toLowerCase(), code)
return false
}
seen.add(identity)
return true
})
if (distinctInputs.length > MAX_DISPOSAL_BATCH_ROOTS) return errorResponse(res, limitMessage, 422)
const trees = await loadDisposalTrees(prisma, roots)
const treeIds = [...new Set([...trees.values()].flatMap(tree => tree.map(item => item.id)))]
const occupancy = treeIds.length ? await getDisposalOccupancy(prisma, treeIds) : undefined
const candidates = distinctInputs.map(({ label, root }) => {
const tree = root ? trees.get(root.id)! : []
return {
equipment_id: root?.id ?? null, equipment_code: root?.equipment_code ?? label,
equipment_name: root?.equipment_name ?? '', status: root?.status ?? '未找到',
items: tree.map(({ id, equipment_code, equipment_name }) => ({ id, equipment_code, equipment_name })),
blocked_reason: root ? getDisposalBlockedReason(tree, occupancy) : '未找到资产'
}
})
successResponse(res, { candidates, duplicate_codes: [...duplicateCodes.values()] }, '批量预检完成')
} catch (error: any) {
errorResponse(res, error.message || '批量报废预检失败', error.status || 422)
}
}
export const createBatchDisposal = async (req: Request, res: Response) => {
try {
const userId = getCurrentUser(res).id
if (!isEquipmentId(userId)) return errorResponse(res, '用户未登录', 401)
const { assets } = req.body || {}
if (!Array.isArray(assets) || assets.length < 1 || assets.length > MAX_DISPOSAL_BATCH_ROOTS) {
return errorResponse(res, `每批须包含 1 至 ${MAX_DISPOSAL_BATCH_ROOTS} 个主资产`, 422)
}
const input = readDisposalSubmission(req.body)
const rootIds: number[] = []
const confirmed = new Map<number, number[]>()
const lockedIds = new Set<number>()
for (const asset of assets) {
if (!isEquipmentId(asset?.equipment_id) || !Array.isArray(asset.item_ids) ||
!asset.item_ids.every(isEquipmentId) || !asset.item_ids.includes(asset.equipment_id)) {
return errorResponse(res, '请逐项确认报废明细,明细必须包含主资产', 422)
}
if (confirmed.has(asset.equipment_id)) return errorResponse(res, '批量报废包含重复主资产', 422)
for (const id of asset.item_ids as number[]) {
if (lockedIds.has(id)) return errorResponse(res, '批量报废明细包含重复资产', 422)
lockedIds.add(id)
}
rootIds.push(asset.equipment_id)
confirmed.set(asset.equipment_id, asset.item_ids)
}
const disposalIds = await prisma.$transaction(async tx => {
// All confirmed assets are locked in ID order. Re-read the current trees
// after locking; any unconfirmed descendant aborts before the first write.
await lockEquipmentRows(tx, [...lockedIds])
const roots = await tx.equipment.findMany({ where: { id: { in: rootIds } }, select: disposalTreeSelect })
if (roots.length !== rootIds.length) throw new DisposalConflictError('有主资产已不存在,请重新预检')
const trees = await loadDisposalTrees(tx, roots)
for (const id of rootIds) {
const tree = trees.get(id)!
const blocked = getDisposalBlockedReason(tree)
if (blocked) throw new DisposalConflictError(`${tree[0].equipment_code}:${blocked}`)
assertDisposalItemsConfirmed(tree, confirmed.get(id)!)
}
const occupancy = await getDisposalOccupancy(tx, [...lockedIds])
for (const id of rootIds) {
const tree = trees.get(id)!
const blocked = getDisposalBlockedReason(tree, occupancy)
if (blocked) throw new DisposalConflictError(`${tree[0].equipment_code}:${blocked}`)
}
const created = []
for (const id of rootIds) created.push(await writeDisposalSubmission(tx, trees.get(id)!, userId, input))
const codes = rootIds.map(id => trees.get(id)![0].equipment_code)
const ids = created.map(row => row.id)
const userName = res.locals.user?.username || res.locals.user?.name || 'unknown'
await tx.operationLog.createMany({ data: codes.map((code, index) => ({
user_id: userId, username: userName, operation_type: '报废申请',
operation_details: `申请报废资产 ${code},原因:${input.reason}`,
equipment_id: rootIds[index], equipment_code: code, ip_address: req.ip || null
})) })
const recipients = await getSubmissionRecipients(tx, userId)
const preview = codes.slice(0, 10).join('、') + (codes.length > 10 ? ` 等 ${codes.length} 个` : '')
await createBusinessNotification(tx, {
...recipients, eventType: 'disposal_applied', businessType: 'disposal', businessId: ids[0],
title: `待审批报废:${ids.length} 笔`,
content: `${res.locals.user?.name || userName} 提交了 ${ids.length} 笔报废申请:${preview}`,
level: 'warning', actionUrl: '/workflows/disposals',
metadata: { disposal_ids: ids, equipment_codes: codes, count: ids.length },
dingTalk: {
title: '批量报废待审批', actionTitle: '查看待审批', messageVersion: 'v1',
content: [
`**申请时间:** ${formatBeijingDateTime(created[0].applied_at)}`,
`**待审批:** ${ids.length} 笔(${escapeNotificationMarkdownInline(preview)})`,
`**申请人:** ${escapeNotificationMarkdownInline(res.locals.user?.name || userName)}`,
`**报废原因:** ${escapeNotificationMarkdownInline(input.reason.slice(0, 300))}`,
'> 请在报废流程列表中逐笔核对并审批。'
].join('\n\n')
}
})
return ids
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, timeout: 30000 })
successResponse(res, { count: disposalIds.length, disposal_ids: disposalIds }, '批量报废申请创建成功')
} 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 currentPage = parseListPositiveInteger(page, '页码', 1)
const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 10, 100)
const currentUser = getCurrentUser(res)
if (!Number.isInteger(currentUser.id) || currentUser.id <= 0) {
return errorResponse(res, '用户未登录', 401)
}
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 summaryConditions: Prisma.DisposalRecordWhereInput[] = [getDisposalVisibility(res)]
if (currentUser.role === 'administrative' && resolvedScope === 'all') {
return errorResponse(res, '行政仅可查看本人发起、全部待审批和全部已处理报废流程', 403)
}
if (currentUser.role !== 'it') {
if (resolvedScope === 'mine') {
summaryConditions.push({ applied_by: currentUser.id })
} else if (resolvedScope === 'pending') {
summaryConditions.push({ status: 'pending' })
} else if (resolvedScope === 'processed') {
summaryConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
}
}
const normalizedStatus = String(status || '').trim()
if (normalizedStatus && !DISPOSAL_STATUSES.includes(normalizedStatus as any)) {
return errorResponse(res, '报废状态无效', 422)
}
if (equipment_id !== undefined && equipment_id !== '') {
summaryConditions.push({
equipment_id: parseListPositiveInteger(equipment_id, '资产ID', 0)
})
}
const normalizedKeyword = String(keyword || '').trim()
if (normalizedKeyword) {
const keywordWhere = [
{ equipment_code: { contains: normalizedKeyword } },
{ disposal_reason: { contains: normalizedKeyword } },
{ items: { some: { equipment_code: { contains: normalizedKeyword } } } }
]
summaryConditions.push({ OR: keywordWhere })
}
const summaryWhere: Prisma.DisposalRecordWhereInput = summaryConditions.length > 0
? { AND: summaryConditions }
: {}
const where: Prisma.DisposalRecordWhereInput = normalizedStatus
? { AND: [summaryWhere, { status: normalizedStatus }] }
: summaryWhere
const skip = (currentPage - 1) * currentPageSize
const [list, total, statusGroups] = await Promise.all([
prisma.disposalRecord.findMany({
where,
skip,
take: currentPageSize,
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 }),
prisma.disposalRecord.groupBy({
by: ['status'],
where: summaryWhere,
_count: { _all: true }
})
])
const summary = {
total: 0,
pending: 0,
approved: 0,
rejected: 0,
completed: 0
}
for (const group of statusGroups) {
const count = group._count._all
summary.total += count
if (DISPOSAL_STATUSES.includes(group.status as any)) {
summary[group.status as (typeof DISPOSAL_STATUSES)[number]] = count
}
}
successResponse(
res,
{
list: list.map(withHistoricalDisposalNotes),
total,
page: currentPage,
pageSize: currentPageSize,
summary
},
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取报废记录列表失败', error.status || 500)
}
}
export const getDisposalDetail = async (req: Request, res: Response) => {
try {
const visibility = getDisposalVisibility(res)
const disposalId = parseListPositiveInteger(req.params.id, '报废记录ID', 0)
const disposal = await prisma.disposalRecord.findUnique({
where: { id: disposalId },
include: disposalInclude
})
if (!disposal) return errorResponse(res, '报废记录不存在', 404)
if (visibility.applied_by && disposal.applied_by !== visibility.applied_by) {
return errorResponse(res, '无权查看该报废申请', 403)
}
successResponse(res, withHistoricalDisposalNotes(disposal), '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取报废记录详情失败', error.status || 500)
}
}
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 visibility = getDisposalVisibility(res)
const equipmentId = parseListPositiveInteger(req.params.equipment_id, '资产ID', 0)
const history = await prisma.disposalRecord.findMany({
where: {
AND: [visibility, { 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 || '获取报废历史失败', error.status || 500)
}
}