feat: add batch disposal and shared HR notifications
This commit is contained in:
parent
83fcfbca3e
commit
961ce04d77
@ -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=供应商,快递公司,外部交流
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
94
backend/src/utils/disposalSubmission.ts
Normal file
94
backend/src/utils/disposalSubmission.ts
Normal 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('、')} 仍处于挂载状态;请先卸载未勾选配件或重新核对后提交`
|
||||
)
|
||||
}
|
||||
@ -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:-供应商,快递公司,外部交流}
|
||||
|
||||
@ -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
|
||||
|
||||
218
frontend/src/components/BatchDisposalDialog.vue
Normal file
218
frontend/src/components/BatchDisposalDialog.vue
Normal 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>
|
||||
27
frontend/src/utils/disposalImport.ts
Normal file
27
frontend/src/utils/disposalImport.ts
Normal 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
|
||||
}
|
||||
@ -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) ||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user