895 lines
37 KiB
TypeScript
895 lines
37 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, 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)
|
||
}
|
||
}
|