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 = (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 = (record: T) => ({ ...record, historical_notes: record.status === 'completed' && record.notes && !record.completion_notes ? record.notes : null }) const readDisposalSubmission = (body: Record) => { 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, 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 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() 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() const duplicateCodes = new Map() 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() const lockedIds = new Set() 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 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) } }