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 = (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 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 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 || '获取报废历史失败') } }