feat: add batch disposal and shared HR notifications

This commit is contained in:
WuYongJie 2026-11-10 10:19:25 +08:00
parent 83fcfbca3e
commit 961ce04d77
12 changed files with 855 additions and 114 deletions

View File

@ -23,6 +23,7 @@ HR_SYNC_SCHEDULE=03:00
HR_SYNC_SCHEDULES=
# 离职自动入库起算时间,使用 UTC ISO 8601 格式。
HR_SYNC_AUTO_RETURN_FROM=
HR_SYNC_NOTIFICATION_EXTRA_USER_IDS=
HR_SYNC_ALLOWED_STATUS_VALUES=3
HR_SYNC_INACTIVE_STATUS_VALUES=5
HR_SYNC_EXCLUDED_DEPARTMENTS=供应商,快递公司,外部交流

View File

@ -0,0 +1,159 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { randomUUID } from 'node:crypto'
test('批量报废在本机隔离 MySQL 验证完整资产树、事务和审批通知', {
skip: process.env.RUN_BUSINESS_DB_TESTS !== 'true'
}, async t => {
const url = new URL(process.env.DATABASE_URL || '')
assert.ok(url.protocol === 'mysql:' && ['127.0.0.1', 'localhost'].includes(url.hostname)
&& url.port === '13309' && url.pathname === '/equipment_audit' && !url.search && !url.hash)
const prisma = (await import('../../utils/prisma')).default
const { createBatchDisposal, previewBatchDisposal } = await import('../disposalController')
const prefix = `disp-${randomUUID().slice(0, 12)}`
const userIds: number[] = []
const personIds: number[] = []
const env = { YYD_NOTIFICATION_SEND_ENABLED: 'true', YYD_NOTIFICATION_SEND_FULL_ROLLOUT: 'true' }
const previousEnv = Object.fromEntries(Object.keys(env).map(key => [key, process.env[key]]))
Object.assign(process.env, env)
try {
const applicant = await prisma.user.create({ data: { username: `${prefix}-it`, name: prefix, password: 'local-test-only', role: 'it' } })
userIds.push(applicant.id)
const person = await prisma.person.create({ data: { nickname: prefix, real_name: prefix, source_userid: `${prefix}-reviewer` } })
personIds.push(person.id)
const reviewer = await prisma.user.create({ data: { username: `${prefix}-reviewer`, name: prefix,
password: 'local-test-only', role: 'administrative', person_id: person.id } })
userIds.push(reviewer.id)
const data = (suffix: string, parent_id: number | null = null) => ({
equipment_code: `${prefix}-${suffix}`, equipment_name: `测试资产 ${suffix}`, equipment_type: '报废测试',
parent_id, mount_status: parent_id ? 'mounted' : 'none', status: '可用',
total_quantity: 1, remaining_quantity: 1, unit_price: 100, total_value: 100, available_value: 100
})
const call = async (handler: typeof createBatchDisposal, body: unknown, ip = '127.0.0.1') => {
const state = { status: 200, body: null as any }
const response = { locals: { user: applicant }, status(code: number) { state.status = code; return this },
json(payload: any) { state.body = payload; return this } }
await handler({ body, ip } as any, response as any)
return state
}
const submit = (assets: { equipment_id: number; item_ids: number[] }[], ip?: string) => call(createBatchDisposal,
{ assets, disposal_reason: '设备老化', disposal_method: '报废', notes: '本机验证' }, ip)
const codes = Array.from({ length: 335 }, (_, index) => data(`list-${String(index).padStart(3, '0')}`))
await prisma.equipment.createMany({ data: codes })
const roots = await prisma.equipment.findMany({ where: { equipment_code: { in: codes.map(row => row.equipment_code) } }, orderBy: { equipment_code: 'asc' } })
const root = roots[0]
const child = await prisma.equipment.create({ data: data('child', root.id) })
const nested = await prisma.equipment.create({ data: data('nested', child.id) })
const loaned = await prisma.equipment.create({ data: data('loan') })
await prisma.borrowRecord.create({ data: { equipment_id: loaned.id, equipment_code: loaned.equipment_code, person_id: person.id,
person_name: person.real_name, person_nickname: person.nickname, status: '已借出' } })
const repairing = await prisma.equipment.create({ data: data('repair') })
await prisma.maintenanceRecord.create({ data: { equipment_id: repairing.id, equipment_code: repairing.equipment_code,
issue_description: '维修中', estimated_cost: 0, created_by: applicant.id } })
const pending = await prisma.equipment.create({ data: data('pending') })
await prisma.disposalRecord.create({ data: { equipment_id: pending.id, equipment_code: pending.equipment_code,
disposal_reason: '旧待审批', disposal_method: '报废', estimated_value: 0, applied_by: applicant.id } })
await t.test('335 个编码只读预检,根包含递归配件,去重与明确阻止各自独立', async t => {
const start = performance.now()
const distinctCodes = codes.map(row => row.equipment_code)
const response = await call(previewBatchDisposal, {
equipment_codes: [...distinctCodes, ...distinctCodes.slice(0, 166).map(code => ` ${code.toUpperCase()} `)]
})
t.diagnostic(`501 条输入(335 条唯一)本机预检用时 ${Math.round(performance.now() - start)} ms`)
assert.equal(response.status, 200, JSON.stringify(response.body))
assert.equal(response.body.data.candidates.length, 335)
assert.deepEqual(response.body.data.candidates.map((row: { equipment_code: string }) => row.equipment_code), distinctCodes)
assert.deepEqual(response.body.data.duplicate_codes, distinctCodes.slice(0, 166))
assert.deepEqual(response.body.data.candidates[0].items.map((row: { id: number }) => row.id), [root.id, child.id, nested.id])
assert.ok(response.body.data.candidates.every((row: { blocked_reason: string | null }) => row.blocked_reason === null))
const mixed = await call(previewBatchDisposal, {
equipment_ids: [root.id], equipment_codes: [root.equipment_code.toUpperCase(), child.equipment_code, 'missing-code',
loaned.equipment_code, repairing.equipment_code, pending.equipment_code]
})
assert.equal(mixed.status, 200, JSON.stringify(mixed.body))
const [first, mounted, missing, borrowed, maintained, disposing] = mixed.body.data.candidates
assert.equal(first.equipment_id, root.id); assert.equal(mounted.equipment_id, child.id)
assert.match(mounted.blocked_reason, /挂载/); assert.equal(missing.equipment_id, null); assert.match(missing.blocked_reason, /未找到/)
assert.match(borrowed.blocked_reason, /未归还/); assert.match(maintained.blocked_reason, /维修/); assert.match(disposing.blocked_reason, /已有/)
assert.deepEqual(mixed.body.data.duplicate_codes, [root.equipment_code])
const rootIds = roots.map(row => row.id)
const mixedAliases = await call(previewBatchDisposal, {
equipment_ids: [...rootIds, ...rootIds.slice(0, 166)], equipment_codes: distinctCodes
})
assert.equal(mixedAliases.status, 200)
assert.equal(mixedAliases.body.data.candidates.length, 335)
assert.deepEqual(mixedAliases.body.data.duplicate_codes, distinctCodes)
const overflow = Array.from({ length: 501 }, (_, index) => `missing-${index}`)
assert.equal((await call(previewBatchDisposal, { equipment_codes: overflow })).status, 422)
assert.equal((await call(previewBatchDisposal, { equipment_ids: rootIds, equipment_codes: overflow.slice(0, 166) })).status, 422)
assert.equal(await prisma.equipment.count({ where: { id: { in: roots.map(row => row.id) }, status: '可用' } }), 335)
})
await t.test('变化后的树必须重新确认,后置审计写入失败回滚前面的全部状态和单据', async () => {
const independent = roots[1]
const stale = await submit([{ equipment_id: independent.id, item_ids: [independent.id] }, { equipment_id: root.id, item_ids: [root.id, child.id] }])
assert.equal(stale.status, 409); assert.match(stale.body.message, new RegExp(nested.equipment_code))
const overlap = await submit([{ equipment_id: root.id, item_ids: [root.id, child.id, nested.id] }, { equipment_id: child.id, item_ids: [child.id] }])
assert.equal(overlap.status, 422)
const occupied = await submit([{ equipment_id: independent.id, item_ids: [independent.id] }, { equipment_id: loaned.id, item_ids: [loaned.id] }])
assert.equal(occupied.status, 409); assert.match(occupied.body.message, /未归还/)
const ids = [independent.id, root.id, child.id, nested.id]
const failed = await submit([{ equipment_id: independent.id, item_ids: [independent.id] },
{ equipment_id: root.id, item_ids: [root.id, child.id, nested.id] }], 'x'.repeat(60))
assert.equal(failed.status, 422); assert.match(failed.body.message, /ip_address/)
assert.equal(await prisma.disposalRecord.count({ where: { equipment_id: { in: ids } } }), 0)
assert.equal(await prisma.equipment.count({ where: { id: { in: ids }, status: '可用' } }), 4)
assert.equal(await prisma.notification.count({ where: { type: 'disposal', metadata: { contains: prefix } } }), 0)
})
await t.test('335 根各生成一张待审批单,整树进入待报废,只发送一条聚合审批通知', async t => {
const start = performance.now()
const result = await submit(roots.map(row => ({ equipment_id: row.id, item_ids: row.id === root.id ? [root.id, child.id, nested.id] : [row.id] })))
t.diagnostic(`335 根本机提交用时 ${Math.round(performance.now() - start)} ms`)
assert.equal(result.status, 200, JSON.stringify(result.body))
const { count, disposal_ids: ids } = result.body.data
assert.equal(count, 335); assert.equal(new Set(ids).size, 335)
assert.equal(await prisma.disposalRecord.count({ where: { id: { in: ids }, status: 'pending', applied_by: applicant.id } }), 335)
assert.equal(await prisma.disposalAction.count({ where: { disposal_id: { in: ids }, action_type: 'applied' } }), 335)
assert.equal(await prisma.operationLog.count({ where: { user_id: applicant.id, operation_type: '报废申请' } }), 335)
const detail = await prisma.disposalItem.findMany({ where: { disposal_id: ids[0] } })
assert.deepEqual(new Set(detail.map(row => row.equipment_id)), new Set([root.id, child.id, nested.id]))
assert.equal(await prisma.equipment.count({ where: { id: { in: [...roots.map(row => row.id), child.id, nested.id] }, status: '待报废' } }), 337)
const notices = await prisma.notification.findMany({ where: { type: 'disposal', metadata: { contains: prefix } }, include: { recipients: true } })
assert.equal(notices.length, 1); assert.equal(notices[0].action_url, '/workflows/disposals')
assert.deepEqual(JSON.parse(notices[0].metadata!).disposal_ids, ids)
assert.ok(notices[0].recipients.some(row => row.user_id === reviewer.id))
const deliveries = await prisma.dingTalkDelivery.findMany({ where: { business_type: 'disposal', business_id: { in: ids }, recipient_user_id: reviewer.id } })
assert.equal(deliveries.length, 1); assert.equal(deliveries[0].status, 'pending')
assert.match(JSON.parse(deliveries[0].payload_json).content, /335 笔/)
const repeated = await submit([{ equipment_id: root.id, item_ids: [root.id, child.id, nested.id] }])
assert.equal(repeated.status, 409)
})
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
try {
await prisma.$transaction(async tx => {
const fixtures = await tx.equipment.findMany({ where: { equipment_code: { startsWith: prefix } }, select: { id: true } })
const equipment_id = { in: fixtures.map(row => row.id) }
const disposals = await tx.disposalRecord.findMany({ where: { equipment_id }, select: { id: true } })
const disposalIds = disposals.map(row => row.id)
await tx.dingTalkDelivery.deleteMany({ where: { business_type: 'disposal', business_id: { in: disposalIds } } })
await tx.notification.deleteMany({ where: { type: 'disposal', metadata: { contains: prefix } } })
await tx.operationLog.deleteMany({ where: { user_id: { in: userIds } } })
await tx.disposalRecord.deleteMany({ where: { id: { in: disposalIds } } })
await tx.borrowRecord.deleteMany({ where: { equipment_id } })
await tx.maintenanceRecord.deleteMany({ where: { equipment_id } })
await tx.equipment.updateMany({ where: { id: equipment_id }, data: { parent_id: null } })
await tx.equipment.deleteMany({ where: { id: equipment_id } })
await tx.user.deleteMany({ where: { id: { in: userIds } } })
await tx.person.deleteMany({ where: { id: { in: personIds } } })
}, { timeout: 20000 })
} finally { await prisma.$disconnect() }
}
})

View File

@ -4,7 +4,11 @@ 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 { loadAndLockAssetTree, lockEquipmentRows } from '../services/assetMovementService'
import {
assertDisposalItemsConfirmed, DisposalConflictError, disposalTreeSelect, getDisposalBlockedReason,
getDisposalOccupancy, loadDisposalTrees, MAX_DISPOSAL_BATCH_ROOTS, type DisposalTreeItem
} from '../utils/disposalSubmission'
import {
createBusinessNotification,
escapeNotificationMarkdownInline,
@ -17,12 +21,6 @@ import {
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 } },
@ -251,15 +249,54 @@ const withHistoricalDisposalNotes = <T extends {
: 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,
disposal_reason,
disposal_method,
estimated_value,
notes
estimated_value
} = req.body as Record<string, any>
const userId = Number(res.locals.user?.id)
const rootId = Number(equipment_id)
@ -267,9 +304,7 @@ export const createDisposal = async (req: Request, res: Response) => {
if (!Number.isInteger(rootId) || rootId <= 0) {
return errorResponse(res, '请选择要报废的主资产', 422)
}
if (!disposal_reason?.trim() || !disposal_method?.trim()) {
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)
@ -285,93 +320,13 @@ export const createDisposal = async (req: Request, res: Response) => {
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)
}
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, {
@ -392,7 +347,7 @@ export const createDisposal = async (req: Request, res: Response) => {
equipmentName: root.equipment_name,
equipmentCode: root.equipment_code,
applicantName: res.locals.user?.name || res.locals.user?.username,
reason: disposal_reason
reason: input.reason
}),
actionTitle: '立即审批',
messageVersion: 'v1'
@ -410,7 +365,7 @@ export const createDisposal = async (req: Request, res: Response) => {
userId,
res.locals.user?.username || res.locals.user?.name || 'unknown',
'报废申请',
`申请报废资产 ${disposal?.equipment_code},原因:${disposal_reason}`,
`申请报废资产 ${disposal?.equipment_code},原因:${input.reason}`,
rootId,
disposal?.equipment_code,
req.ip
@ -422,6 +377,152 @@ export const createDisposal = async (req: Request, res: Response) => {
}
}
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

View File

@ -1,6 +1,8 @@
import { Router } from 'express'
import {
createDisposal,
createBatchDisposal,
previewBatchDisposal,
getDisposalList,
getDisposalDetail,
approveDisposal,
@ -15,6 +17,8 @@ const router = Router()
router.use(protect())
router.post('/', requirePermission(PERMISSIONS.DISPOSALS_APPLY), createDisposal)
router.post('/batch/preview', requirePermission(PERMISSIONS.DISPOSALS_APPLY), previewBatchDisposal)
router.post('/batch', requirePermission(PERMISSIONS.DISPOSALS_APPLY), createBatchDisposal)
router.get('/list', requirePermission(PERMISSIONS.NOTIFICATIONS_VIEW), getDisposalList)
router.post(
'/equipment/:equipment_id/return-to-storage',

View File

@ -1,5 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import type { Prisma } from '@prisma/client'
import {
buildHrSyncPreviewLine,
@ -11,6 +12,46 @@ import {
shouldNotifyHrSync,
shouldUseSourceDepartmentBinding
} from '../hrSyncHelpers'
import { resolveHrSyncNotificationRecipients } from '../hrSyncService'
test('花名册变动通知加入指定在用账号,保留角色路由并按用户去重', async () => {
const keys = ['HR_SYNC_NOTIFICATION_EXTRA_USER_IDS', 'YYD_NOTIFICATION_SEND_ENABLED',
'YYD_NOTIFICATION_SEND_FULL_ROLLOUT']
const previous = new Map(keys.map(key => [key, process.env[key]]))
process.env.HR_SYNC_NOTIFICATION_EXTRA_USER_IDS = ' 10, 20, 20, 40, invalid '
process.env.YYD_NOTIFICATION_SEND_ENABLED = 'true'
process.env.YYD_NOTIFICATION_SEND_FULL_ROLLOUT = 'true'
const users = [
{ id: 10, role: 'it', status: 'active' },
{ id: 20, role: 'admin', status: 'active' },
{ id: 30, role: 'admin', status: 'active' },
{ id: 40, role: 'admin', status: 'inactive' }
]
const client = {
user: {
findMany: async ({ where }: {
where: { id?: { in: number[] }; role?: { in: string[] }; status: string }
}) => users.filter(user => user.status === where.status &&
(!where.id || where.id.in.includes(user.id)) && (!where.role || where.role.in.includes(user.role)))
.map(user => ({ id: user.id, person: { source_userid: `dingtalk-${user.id}` } }))
}
} as unknown as Prisma.TransactionClient
try {
const success = await resolveHrSyncNotificationRecipients(client, 'success')
assert.deepEqual(success.stationRecipients.map(user => user.id), [10, 20])
assert.deepEqual(success.dingTalkRecipients.map(user => user.id), [10, 20])
const failed = await resolveHrSyncNotificationRecipients(client, 'failed')
assert.deepEqual(failed.stationRecipients.map(user => user.id), [20, 30])
assert.deepEqual(failed.dingTalkRecipients.map(user => user.id), [20, 30])
} finally {
for (const [key, value] of previous) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}
})
test('resolvePersonStatus 默认应将状态值 2 视为 active,仅将状态值 5 视为 inactive', () => {
const previous = process.env.HR_SYNC_INACTIVE_STATUS_VALUES

View File

@ -1,6 +1,7 @@
import prisma from '../utils/prisma'
import { assertHrSyncSourceSafe } from '../utils/hrSyncSafety'
import sql from 'mssql'
import type { Prisma } from '@prisma/client'
import {
HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM,
HR_SOURCE_SYSTEM,
@ -22,8 +23,11 @@ import {
} from './hrSyncHelpers'
import {
createBusinessNotification,
findActiveDingTalkUsersByIds,
findActiveDingTalkUsersByRoles,
findActiveStationUsersByIds,
findActiveStationUsersByRoles,
mergeRecipients,
type NotificationRecipient,
type StationNotificationRecipient
} from './businessNotificationService'
@ -1118,6 +1122,36 @@ const summarizeHrSyncChanges = (changes: HrSyncChangeDraft[]): HrSyncSummary =>
}
}
export const resolveHrSyncNotificationRecipients = async (
client: Prisma.TransactionClient,
status: 'success' | 'failed'
) => {
const { primaryRoles, fallbackRoles } = resolveHrSyncNotificationRoleRoute(status)
let stationRecipients = await findActiveStationUsersByRoles(client, primaryRoles)
if (stationRecipients.length === 0 && fallbackRoles.length > 0) {
stationRecipients = await findActiveStationUsersByRoles(client, fallbackRoles)
}
let dingTalkRecipients = await findActiveDingTalkUsersByRoles(client, primaryRoles)
if (dingTalkRecipients.length === 0 && fallbackRoles.length > 0) {
dingTalkRecipients = await findActiveDingTalkUsersByRoles(client, fallbackRoles)
}
const extraUserIds = status === 'success'
? (process.env.HR_SYNC_NOTIFICATION_EXTRA_USER_IDS || '').split(',')
.map(value => Number(value.trim()))
.filter(id => Number.isSafeInteger(id) && id > 0)
: []
const [extraStationRecipients, extraDingTalkRecipients] = await Promise.all([
findActiveStationUsersByIds(client, extraUserIds),
findActiveDingTalkUsersByIds(client, extraUserIds)
])
return {
stationRecipients: mergeRecipients(stationRecipients, extraStationRecipients),
dingTalkRecipients: mergeRecipients(dingTalkRecipients, extraDingTalkRecipients)
}
}
const persistHrSyncArtifacts = async ({
operator,
triggerMode,
@ -1138,15 +1172,9 @@ const persistHrSyncArtifacts = async ({
let stationRecipients: StationNotificationRecipient[] = []
let dingTalkRecipients: NotificationRecipient[] = []
if (shouldNotifyHrSync(status, observedChanges.length + pending.length)) {
const roleRoute = resolveHrSyncNotificationRoleRoute(status)
stationRecipients = await findActiveStationUsersByRoles(tx, roleRoute.primaryRoles)
if (stationRecipients.length === 0 && roleRoute.fallbackRoles.length > 0) {
stationRecipients = await findActiveStationUsersByRoles(tx, roleRoute.fallbackRoles)
}
dingTalkRecipients = await findActiveDingTalkUsersByRoles(tx, roleRoute.primaryRoles)
if (dingTalkRecipients.length === 0 && roleRoute.fallbackRoles.length > 0) {
dingTalkRecipients = await findActiveDingTalkUsersByRoles(tx, roleRoute.fallbackRoles)
}
const recipients = await resolveHrSyncNotificationRecipients(tx, status)
stationRecipients = recipients.stationRecipients
dingTalkRecipients = recipients.dingTalkRecipients
}
const hasRecipients = stationRecipients.length > 0 || dingTalkRecipients.length > 0
const changes = hasRecipients

View File

@ -0,0 +1,94 @@
import type { Prisma } from '@prisma/client'
export const MAX_DISPOSAL_BATCH_ROOTS = 500
export const disposalTreeSelect = {
id: true, equipment_code: true, equipment_name: true, parent_id: true,
department_id: true, storage_location_id: true, status: true, mount_status: true,
total_quantity: true, remaining_quantity: true, unit_price: true, updated_at: true
} as const
export type DisposalTreeItem = Prisma.EquipmentGetPayload<{ select: typeof disposalTreeSelect }>
export class DisposalConflictError extends Error {
status = 409
}
const blockedStatuses = new Set(['已借出', '维护中', '待报废', '已报废', '已丢失'])
// One lookup per depth across the entire set of candidates, not per asset.
export const loadDisposalTrees = async (client: Prisma.TransactionClient, roots: DisposalTreeItem[]) => {
const byParent = new Map<number, DisposalTreeItem[]>()
const visited = new Set(roots.map(root => root.id))
let frontier = [...visited]
while (frontier.length > 0) {
const children = await client.equipment.findMany({
where: { parent_id: { in: frontier } }, select: disposalTreeSelect, orderBy: { id: 'asc' }
})
frontier = []
for (const child of children) {
const parentId = child.parent_id!
const siblings = byParent.get(parentId)
if (siblings) siblings.push(child)
else byParent.set(parentId, [child])
if (!visited.has(child.id)) {
visited.add(child.id)
frontier.push(child.id)
}
}
}
return new Map(roots.map(root => {
const tree = [root]
const seen = new Set([root.id])
for (const item of tree) {
for (const child of byParent.get(item.id) || []) {
if (seen.has(child.id)) throw new DisposalConflictError('资产挂载关系存在循环,请先核查')
seen.add(child.id)
tree.push(child)
}
}
return [root.id, tree] as const
}))
}
export const getDisposalOccupancy = async (client: Prisma.TransactionClient, ids: number[]) => {
const equipmentIds = { in: ids }
const [loans, maintenance, disposals] = await Promise.all([
client.borrowRecord.findMany({ where: { equipment_id: equipmentIds, status: '已借出' }, select: { equipment_id: true }, distinct: ['equipment_id'] }),
client.maintenanceRecord.findMany({ where: { equipment_id: equipmentIds, status: 'in_progress' }, select: { equipment_id: true }, distinct: ['equipment_id'] }),
client.disposalRecord.findMany({
where: { status: { in: ['pending', 'approved'] }, OR: [{ equipment_id: equipmentIds }, { items: { some: { equipment_id: equipmentIds } } }] },
select: { equipment_id: true, items: { where: { equipment_id: equipmentIds }, select: { equipment_id: true } } }
})
])
return {
borrowed: new Set(loans.map(row => row.equipment_id)),
maintenance: new Set(maintenance.map(row => row.equipment_id)),
disposal: new Set(disposals.flatMap(row => [row.equipment_id, ...row.items.map(item => item.equipment_id)]))
}
}
export const getDisposalBlockedReason = (tree: DisposalTreeItem[], occupancy?: Awaited<ReturnType<typeof getDisposalOccupancy>>) => {
if (tree[0].parent_id !== null) return '挂载中的子资产不能单独申请报废,请先卸载'
const blocked = tree.find(item => blockedStatuses.has(item.status))
if (blocked) return blocked.status === '维护中'
? '资产正在维修中,请先完成或取消维修'
: `资产 ${blocked.equipment_code} 当前为${blocked.status},不能申请报废`
if (tree.some(item => occupancy?.borrowed.has(item.id))) return '资产树存在未归还记录,不能申请报废'
if (tree.some(item => occupancy?.maintenance.has(item.id))) return '资产树存在进行中的维修,请先完成或取消维修'
if (tree.some(item => occupancy?.disposal.has(item.id))) return '资产树已有进行中的报废申请'
return null
}
export const assertDisposalItemsConfirmed = (tree: DisposalTreeItem[], requestedIds: number[]) => {
if (!requestedIds.includes(tree[0].id)) throw new Error('报废明细必须包含主资产')
const treeIds = new Set(tree.map(item => item.id))
if (requestedIds.some(id => !treeIds.has(id))) {
throw new DisposalConflictError('报废明细包含不属于当前资产树的资产,请重新核对')
}
const requested = new Set(requestedIds)
const omitted = tree.filter(item => !requested.has(item.id))
if (omitted.length > 0) throw new DisposalConflictError(
`配件 ${omitted.map(item => item.equipment_code).join('、')} 仍处于挂载状态;请先卸载未勾选配件或重新核对后提交`
)
}

View File

@ -55,6 +55,7 @@ services:
HR_SYNC_SCHEDULE: ${HR_SYNC_SCHEDULE:-03:00}
HR_SYNC_SCHEDULES: ${HR_SYNC_SCHEDULES:-}
HR_SYNC_AUTO_RETURN_FROM: ${HR_SYNC_AUTO_RETURN_FROM:-}
HR_SYNC_NOTIFICATION_EXTRA_USER_IDS: ${HR_SYNC_NOTIFICATION_EXTRA_USER_IDS:-}
HR_SYNC_ALLOWED_STATUS_VALUES: ${HR_SYNC_ALLOWED_STATUS_VALUES:-2,3}
HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-5}
HR_SYNC_EXCLUDED_DEPARTMENTS: ${HR_SYNC_EXCLUDED_DEPARTMENTS:-供应商,快递公司,外部交流}

View File

@ -111,6 +111,40 @@ export interface DisposalListData {
export const createDisposal = (data: CreateDisposalParams) => request.post('/disposal', data)
export interface BatchDisposalPreviewParams {
equipment_ids?: number[]
equipment_codes?: string[]
}
export interface BatchDisposalCandidate {
equipment_id: number | null
equipment_code: string
equipment_name: string
status: string
items: Array<{ id: number; equipment_code: string; equipment_name: string }>
blocked_reason: string | null
}
export interface BatchDisposalPreview {
candidates: BatchDisposalCandidate[]
duplicate_codes: string[]
}
export interface CreateBatchDisposalParams {
assets: Array<{ equipment_id: number; item_ids: number[] }>
disposal_reason: string
disposal_method: string
notes?: string
}
export const previewBatchDisposal = (data: BatchDisposalPreviewParams) => request
.post<ApiResponse<BatchDisposalPreview>>('/disposal/batch/preview', data)
.then((response) => response.data)
export const createBatchDisposal = (data: CreateBatchDisposalParams) => request
.post<ApiResponse<{ count: number; disposal_ids: number[] }>>('/disposal/batch', data, { timeout: 60000 })
.then((response) => response.data)
export const returnEquipmentBeforeDisposal = (equipmentId: number, data: {
return_condition: '正常' | '损坏'
confirmation_token?: string

View File

@ -0,0 +1,218 @@
<template>
<div class="sheet-backdrop" @click.self="close">
<form class="sheet-panel space-y-4" role="dialog" aria-modal="true" aria-labelledby="batch-disposal-title" @submit.prevent="submit" @keydown.esc.prevent="close">
<header>
<h3 id="batch-disposal-title" class="section-title">{{ importMode ? '导入报废清单' : '批量报废' }}</h3>
<p class="mt-2 text-sm text-text-secondary">预览并选择要申请的资产。提交后进入报废审批。</p>
</header>
<div v-if="importMode" class="field-stack">
<input id="batch-disposal-file" ref="fileInput" type="file" accept=".xlsx,.xls,.csv" class="hidden" :disabled="busy" @change="handleFile">
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="btn-secondary text-sm" :disabled="busy" @click="fileInput?.click()">{{ fileName ? '更换文件' : '选择报废清单' }}</button>
<span v-if="fileName" class="min-w-0 break-all text-sm text-text-primary">{{ fileName }}</span>
</div>
<p class="text-sm text-text-secondary">支持 Excel、CSV,清单需包含“编码”“资产编码”或“设备编码”列。</p>
</div>
<div v-if="loading || readingFile" class="panel text-sm text-text-secondary" role="status">正在读取并检查报废清单…</div>
<p v-if="errorMessage" class="text-sm text-status-error" role="alert">{{ errorMessage }}</p>
<section v-if="preview" class="space-y-4" aria-label="报废预览">
<div class="flex flex-wrap items-center gap-2" aria-live="polite">
<span class="badge badge-neutral">共 {{ preview.candidates.length }} 项</span>
<span class="badge badge-success">可申请 {{ eligibleCandidates.length }} 项</span>
<span class="badge badge-warning">不可申请 {{ preview.candidates.length - eligibleCandidates.length }} 项</span>
<span class="badge badge-neutral">已选 {{ selectedCandidates.length }} 项</span>
</div>
<p v-if="preview.duplicate_codes.length" class="break-words text-sm text-text-secondary">
重复编码只计一次:{{ preview.duplicate_codes.join('、') }}
</p>
<p v-if="hasEligibleGroups" class="text-sm text-text-secondary">含配件的资产组默认不选中。查看配件后,可勾选确认整组申请。</p>
<div v-if="eligibleCandidates.length" class="flex flex-wrap gap-2">
<button type="button" class="btn-secondary text-sm" :disabled="busy" @click="selectStandalone">选中可申请单件</button>
<button type="button" class="btn-secondary text-sm" :disabled="busy || !selectedCandidates.length" @click="selectedIds = []">清空选择</button>
</div>
<ul v-if="preview.candidates.length" class="max-h-72 space-y-2 overflow-y-auto" aria-label="资产预览列表" tabindex="0">
<li v-for="candidate in preview.candidates" :key="`${candidate.equipment_id ?? ''}:${candidate.equipment_code}`" class="panel space-y-2">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1 break-words">
<p class="text-sm font-semibold text-text-primary">{{ candidate.equipment_name || candidate.equipment_code }}</p>
<p v-if="candidate.equipment_name" class="mt-1 font-mono text-xs text-text-tertiary">{{ candidate.equipment_code }}</p>
</div>
<span class="badge" :class="canApply(candidate) ? 'badge-neutral' : 'badge-warning'">{{ candidate.status || '不可申请' }}</span>
</div>
<p v-if="!canApply(candidate)" class="text-sm text-status-warning">{{ candidate.blocked_reason || '该资产不可申请报废' }}</p>
<details v-if="candidate.items.length > 1" class="text-sm text-text-secondary">
<summary class="cursor-pointer text-text-primary">查看 {{ candidate.items.length - 1 }} 件配件</summary>
<ul class="mt-2 space-y-1 pl-4">
<li v-for="item in candidate.items.filter((item) => item.id !== candidate.equipment_id)" :key="item.id" class="break-words">
<span class="font-mono text-xs">{{ item.equipment_code }}</span> {{ item.equipment_name }}
</li>
</ul>
</details>
<label v-if="canApply(candidate)" class="flex items-center gap-2 text-sm text-text-primary">
<input v-model="selectedIds" type="checkbox" :value="candidate.equipment_id" :disabled="busy" class="rounded">
{{ candidate.items.length > 1 ? '确认整组申请(主资产及配件)' : '申请此资产' }}
</label>
</li>
</ul>
<p v-else class="text-sm text-text-secondary">没有可预览的资产。</p>
</section>
<fieldset v-if="eligibleCandidates.length" class="field-grid" :disabled="busy">
<div class="field-stack">
<label for="batch-disposal-reason" class="field-label">报废原因 *</label>
<textarea id="batch-disposal-reason" v-model="reason" class="input" rows="2" required placeholder="填写本批申请的共同原因"></textarea>
</div>
<div class="field-stack">
<label for="batch-disposal-method" class="field-label">处置方式 *</label>
<select id="batch-disposal-method" v-model="method" class="select">
<option value="报废">报废</option>
<option value="转让">转让</option>
<option value="捐赠">捐赠</option>
</select>
</div>
<div class="field-stack sm:col-span-2">
<label for="batch-disposal-notes" class="field-label">备注</label>
<textarea id="batch-disposal-notes" v-model="notes" class="input" rows="2" placeholder="其他说明"></textarea>
</div>
</fieldset>
<footer class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button type="button" class="btn-secondary" :disabled="submitting" @click="close">取消</button>
<button v-if="lastRequest && errorMessage" type="button" class="btn-secondary" :disabled="busy" @click="loadPreview(lastRequest)">重新预览</button>
<button type="submit" class="btn-danger" :disabled="!canSubmit" :aria-busy="submitting">
{{ submitting ? '正在提交…' : `提交 ${selectedCandidates.length} 项审批` }}
</button>
</footer>
</form>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
createBatchDisposal,
previewBatchDisposal,
type BatchDisposalCandidate,
type BatchDisposalPreview,
type BatchDisposalPreviewParams
} from '@/api/disposal'
import { parseDisposalImport } from '@/utils/disposalImport'
const props = defineProps<{ equipmentIds: number[] }>()
const emit = defineEmits<{ close: []; submitted: [result: { count: number; equipmentIds: number[] }] }>()
const importMode = props.equipmentIds.length === 0
const preview = ref<BatchDisposalPreview | null>(null)
const lastRequest = ref<BatchDisposalPreviewParams | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const fileName = ref('')
const errorMessage = ref('')
const readingFile = ref(false)
const loading = ref(false)
const submitting = ref(false)
const selectedIds = ref<number[]>([])
const reason = ref('')
const method = ref('报废')
const notes = ref('')
const canApply = (candidate: BatchDisposalCandidate): candidate is BatchDisposalCandidate & { equipment_id: number } =>
candidate.equipment_id !== null && candidate.items.length > 0 && !candidate.blocked_reason
const busy = computed(() => readingFile.value || loading.value || submitting.value)
const eligibleCandidates = computed(() => (preview.value?.candidates || []).filter(canApply))
const hasEligibleGroups = computed(() => eligibleCandidates.value.some((candidate) => candidate.items.length > 1))
const selectedCandidates = computed(() => {
const selected = new Set(selectedIds.value)
return Array.from(new Map(eligibleCandidates.value
.filter((candidate) => selected.has(candidate.equipment_id))
.map((candidate) => [candidate.equipment_id, candidate])).values())
})
const canSubmit = computed(() => !busy.value && selectedCandidates.value.length > 0 && Boolean(reason.value.trim()))
const selectStandalone = () => {
selectedIds.value = Array.from(new Set([
...selectedIds.value,
...eligibleCandidates.value.filter((candidate) => candidate.items.length === 1).map((candidate) => candidate.equipment_id)
]))
}
const close = () => {
if (!submitting.value) emit('close')
}
const loadPreview = async (params: BatchDisposalPreviewParams) => {
if (loading.value || submitting.value) return
lastRequest.value = params
preview.value = null
selectedIds.value = []
errorMessage.value = ''
loading.value = true
try {
const response = await previewBatchDisposal(params)
if (!response.data) throw new Error('没有读取到报废预览,请重新预览')
preview.value = response.data
selectStandalone()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '预览失败,请重试'
} finally {
loading.value = false
}
}
const handleFile = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || busy.value) return
fileName.value = file.name
preview.value = null
lastRequest.value = null
selectedIds.value = []
errorMessage.value = ''
readingFile.value = true
try {
const source = /\.csv$/i.test(file.name) ? await file.text() : await file.arrayBuffer()
const codes = parseDisposalImport(source)
if (!codes.length) throw new Error('清单中没有可读取的资产编码,请检查编码列和数据')
await loadPreview({ equipment_codes: codes })
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '清单读取失败,请检查文件格式'
} finally {
readingFile.value = false
}
}
const submit = async () => {
if (!canSubmit.value) return
const assets = selectedCandidates.value.map((candidate) => ({
equipment_id: candidate.equipment_id,
item_ids: candidate.items.map((item) => item.id)
}))
submitting.value = true
errorMessage.value = ''
try {
const response = await createBatchDisposal({
assets,
disposal_reason: reason.value.trim(),
disposal_method: method.value,
notes: notes.value.trim() || undefined
})
if (!response.data) throw new Error('未收到审批结果,请核对报废记录')
emit('submitted', { count: response.data.count, equipmentIds: assets.flatMap((asset) => asset.item_ids) })
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '提交失败,请重试'
} finally {
submitting.value = false
}
}
onMounted(() => {
if (!importMode) void loadPreview({ equipment_ids: props.equipmentIds })
})
</script>

View File

@ -0,0 +1,27 @@
import * as XLSX from 'xlsx'
const codeHeaders = new Set(['编码', '资产编码', '设备编码', '资产编号', '设备编号', '资产代码', '设备代码'])
export const parseDisposalImport = (source: ArrayBuffer | string): string[] => {
const workbook = XLSX.read(source, { type: typeof source === 'string' ? 'string' : 'array', raw: true })
const codes: string[] = []
for (const [index, name] of workbook.SheetNames.entries()) {
if (workbook.Workbook?.Sheets?.[index]?.Hidden) continue
const rows = XLSX.utils.sheet_to_json<string[]>(workbook.Sheets[name], { header: 1, raw: false, defval: '' })
for (const [rowIndex, row] of rows.entries()) {
const columns = row.flatMap((cell, index) => codeHeaders.has(cell.replace(/\s/g, '')) ? [index] : [])
if (!columns.length) continue
for (const dataRow of rows.slice(rowIndex + 1)) {
for (const column of columns) {
const code = dataRow[column]?.trim()
if (code) codes.push(code)
}
}
break
}
}
return codes
}

View File

@ -76,7 +76,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</button>
<div v-if="canExport || canImport" class="relative">
<div v-if="canExport || canImport || canApplyDisposal" class="relative">
<button @click="showMoreActions = !showMoreActions" class="btn-icon h-10 w-10" title="更多操作">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
@ -102,6 +102,9 @@
批量导入
<input type="file" accept=".csv,.xlsx,.xls" class="hidden" @click="showMoreActions = false" @change="handleFileImport" />
</label>
<button v-if="canApplyDisposal" type="button" class="w-full rounded-lg px-3 py-2 text-left text-sm text-text-secondary transition hover:bg-bg-hover hover:text-text-primary" @click="openBatchDisposal(true)">
导入报废清单
</button>
</div>
</div>
<button v-if="hasActiveFilters" @click="handleReset" class="btn-icon h-10 w-10 text-status-error" title="清除筛选">
@ -162,6 +165,11 @@
</div>
</section>
<div v-if="batchDisposalAppliedCount !== null" class="panel flex items-center justify-between gap-4" role="status">
<p class="text-sm text-text-primary">已提交 {{ batchDisposalAppliedCount }} 项报废申请,等待审批。</p>
<button type="button" class="btn-icon h-8 w-8 shrink-0" aria-label="关闭报废申请提示" @click="batchDisposalAppliedCount = null">✕</button>
</div>
<!-- 仅在实际勾选资产后出现,避免闲置操作区长期占用页面 -->
<section
v-if="canSelectAssets && selectedIds.length > 0"
@ -177,6 +185,7 @@
<button v-if="canBorrow" @click="openBatchBorrowModal" class="btn-primary">批量借出</button>
<button v-if="canBorrow" @click="openBatchReturnModal" class="btn-secondary btn-secondary--highlight">批量归还</button>
<button v-if="canTransfer" @click="openBatchTransferModal" class="btn-secondary">批量转移</button>
<button v-if="canApplyDisposal" type="button" @click="openBatchDisposal(false)" class="btn-secondary">批量报废</button>
<button v-if="canPrintLabels" @click="handleBatchPrintBarcode" class="btn-secondary">打印标签</button>
</div>
</section>
@ -751,6 +760,13 @@
</div>
</div>
<BatchDisposalDialog
v-if="canApplyDisposal && batchDisposalIds !== null"
:equipment-ids="batchDisposalIds"
@close="batchDisposalIds = null"
@submitted="handleBatchDisposalSubmitted"
/>
<!-- 异常处理弹窗 -->
<BorrowAnomalyHandler
v-model="showBorrowAnomalyHandler"
@ -784,6 +800,7 @@ import { batchImportEquipments } from '@/api/batchImport'
import { formatCurrencyNoFraction, formatNumber, formatDateTime } from '@/utils/format'
import { canHandleBorrowAnomaly as hasBorrowAnomaly } from '@/utils/borrowAnomaly'
import BarcodePrint from '@/components/BarcodePrint.vue'
import BatchDisposalDialog from '@/components/BatchDisposalDialog.vue'
import BorrowAnomalyHandler from '@/components/BorrowAnomalyHandler.vue'
import CameraScannerModal from '@/components/CameraScannerModal.vue'
import MountHistory from '@/components/MountHistory.vue'
@ -820,7 +837,7 @@ const canMaintain = computed(() => authStore.can(PERMISSIONS.ASSETS_MAINTENANCE)
const canApplyDisposal = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPLY))
const canExport = computed(() => authStore.can(PERMISSIONS.REPORTS_VIEW))
const canPrintLabels = computed(() => authStore.can(PERMISSIONS.ASSETS_PRINT_LABELS))
const canSelectAssets = computed(() => canBorrow.value || canTransfer.value || canPrintLabels.value)
const canSelectAssets = computed(() => canBorrow.value || canTransfer.value || canPrintLabels.value || canApplyDisposal.value)
const list = ref<Equipment[]>([])
const total = ref(0)
const currentPage = ref(1)
@ -866,6 +883,8 @@ const barcodePrintIds = ref<number[]>([])
const showBatchBorrowModal = ref(false)
const showBatchReturnModal = ref(false)
const showBatchTransferModal = ref(false)
const batchDisposalIds = ref<number[] | null>(null)
const batchDisposalAppliedCount = ref<number | null>(null)
const showMoreActions = ref(false)
const showAdvancedFilters = ref(false)
const isExporting = ref(false)
@ -1786,6 +1805,20 @@ const openBatchTransferModal = () => {
showBatchTransferModal.value = true
}
const openBatchDisposal = (importMode: boolean) => {
if (!canApplyDisposal.value || (!importMode && !selectedIds.value.length)) return
showMoreActions.value = false
batchDisposalIds.value = importMode ? [] : [...selectedIds.value]
}
const handleBatchDisposalSubmitted = (result: { count: number; equipmentIds: number[] }) => {
const submittedIds = new Set(result.equipmentIds)
selectedIds.value = selectedIds.value.filter((id) => !submittedIds.has(id))
batchDisposalIds.value = null
batchDisposalAppliedCount.value = result.count
fetchData()
}
function isParentAsset(equipment: Equipment) {
return Boolean(
assetTypes.value.some((assetType) => assetType.can_be_parent && assetType.name === equipment.equipment_type) ||