From 961ce04d77a1b28a370d8b63d7132a4db3cefbe4 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 10 Nov 2026 10:19:25 +0800 Subject: [PATCH] feat: add batch disposal and shared HR notifications --- .env.example | 1 + .../disposalBatch.integration.test.ts | 159 +++++++++ backend/src/controllers/disposalController.ts | 307 ++++++++++++------ backend/src/routes/disposal.ts | 4 + .../services/__tests__/hrSyncService.test.ts | 41 +++ backend/src/services/hrSyncService.ts | 46 ++- backend/src/utils/disposalSubmission.ts | 94 ++++++ docker-compose.yml | 1 + frontend/src/api/disposal.ts | 34 ++ .../src/components/BatchDisposalDialog.vue | 218 +++++++++++++ frontend/src/utils/disposalImport.ts | 27 ++ .../src/views/equipment/EquipmentList.vue | 37 ++- 12 files changed, 855 insertions(+), 114 deletions(-) create mode 100644 backend/src/controllers/__tests__/disposalBatch.integration.test.ts create mode 100644 backend/src/utils/disposalSubmission.ts create mode 100644 frontend/src/components/BatchDisposalDialog.vue create mode 100644 frontend/src/utils/disposalImport.ts diff --git a/.env.example b/.env.example index a7b88b8..624e856 100644 --- a/.env.example +++ b/.env.example @@ -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=供应商,快递公司,外部交流 diff --git a/backend/src/controllers/__tests__/disposalBatch.integration.test.ts b/backend/src/controllers/__tests__/disposalBatch.integration.test.ts new file mode 100644 index 0000000..86333ef --- /dev/null +++ b/backend/src/controllers/__tests__/disposalBatch.integration.test.ts @@ -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() } + } +}) diff --git a/backend/src/controllers/disposalController.ts b/backend/src/controllers/disposalController.ts index 53711d0..9b15254 100644 --- a/backend/src/controllers/disposalController.ts +++ b/backend/src/controllers/disposalController.ts @@ -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 = ) => { + const reason = typeof body.disposal_reason === 'string' ? body.disposal_reason.trim() : '' + const method = typeof body.disposal_method === 'string' ? body.disposal_method.trim() : '' + if (!reason || !method) throw new Error('报废原因和处置方式不能为空') + if (method.length > 50) throw new Error('处置方式不能超过50个字符') + if (body.notes != null && typeof body.notes !== 'string') throw new Error('备注格式无效') + return { reason, method, notes: typeof body.notes === 'string' ? body.notes.trim() || null : null } +} + +const writeDisposalSubmission = async ( + tx: Prisma.TransactionClient, tree: DisposalTreeItem[], userId: number, + input: ReturnType, estimatedValue = 0 +) => { + const root = tree[0] + const codeById = new Map(tree.map(item => [item.id, item.equipment_code])) + const disposal = await tx.disposalRecord.create({ data: { + equipment_id: root.id, + equipment_code: root.equipment_code, + disposal_reason: input.reason, + disposal_method: input.method, + estimated_value: estimatedValue, + applied_by: userId, + pre_status: root.status, + notes: input.notes, + items: { create: tree.map(item => ({ + equipment_id: item.id, equipment_code: item.equipment_code, equipment_name: item.equipment_name, + parent_equipment_id: item.parent_id, + parent_equipment_code: item.parent_id === null ? null : codeById.get(item.parent_id) || null, + pre_status: item.status, + snapshot_json: JSON.stringify({ + equipment_code: item.equipment_code, equipment_name: item.equipment_name, parent_id: item.parent_id, + department_id: item.department_id, storage_location_id: item.storage_location_id, status: item.status + }) + })) }, + actions: { create: { + action_type: 'applied', operator_id: userId, from_status: null, to_status: 'pending', detail: input.reason + } } + }, select: { id: true, applied_at: true } }) + for (const item of tree) await updateEquipmentLifecycle(tx, item, '待报废', item.status) + return disposal +} + export const createDisposal = async (req: Request, res: Response) => { try { const { equipment_id, item_ids, - disposal_reason, - disposal_method, - estimated_value, - notes + estimated_value } = req.body as Record 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() + for (const code of codes) { + const key = code.toLowerCase() + if (!uniqueCodes.has(key)) uniqueCodes.set(key, code) + } + if (uniqueIds.length + uniqueCodes.size === 0 || + uniqueIds.length > MAX_DISPOSAL_BATCH_ROOTS || uniqueCodes.size > MAX_DISPOSAL_BATCH_ROOTS) { + return errorResponse(res, limitMessage, 422) + } + const roots = await prisma.equipment.findMany({ + where: { OR: [{ id: { in: uniqueIds } }, { equipment_code: { in: [...uniqueCodes.values()] } }] }, select: disposalTreeSelect + }) + const byId = new Map(roots.map(root => [root.id, root])) + const byCode = new Map(roots.map(root => [root.equipment_code.toLowerCase(), root])) + const inputs = [ + ...equipment_ids.map(id => ({ key: `id:${id}`, label: String(id), root: byId.get(id), isCode: false })), + ...codes.map(code => ({ key: `code:${code.toLowerCase()}`, label: code, root: byCode.get(code.toLowerCase()), isCode: true })) + ] + const seen = new Set() + const duplicateCodes = new Map() + const distinctInputs = inputs.filter(({ key, label, root, isCode }) => { + const identity = root ? `id:${root.id}` : key + if (seen.has(identity)) { + const code = root?.equipment_code || label + if ((root || isCode) && !duplicateCodes.has(code.toLowerCase())) duplicateCodes.set(code.toLowerCase(), code) + return false + } + seen.add(identity) + return true + }) + if (distinctInputs.length > MAX_DISPOSAL_BATCH_ROOTS) return errorResponse(res, limitMessage, 422) + const trees = await loadDisposalTrees(prisma, roots) + const treeIds = [...new Set([...trees.values()].flatMap(tree => tree.map(item => item.id)))] + const occupancy = treeIds.length ? await getDisposalOccupancy(prisma, treeIds) : undefined + const candidates = distinctInputs.map(({ label, root }) => { + const tree = root ? trees.get(root.id)! : [] + return { + equipment_id: root?.id ?? null, equipment_code: root?.equipment_code ?? label, + equipment_name: root?.equipment_name ?? '', status: root?.status ?? '未找到', + items: tree.map(({ id, equipment_code, equipment_name }) => ({ id, equipment_code, equipment_name })), + blocked_reason: root ? getDisposalBlockedReason(tree, occupancy) : '未找到资产' + } + }) + successResponse(res, { candidates, duplicate_codes: [...duplicateCodes.values()] }, '批量预检完成') + } catch (error: any) { + errorResponse(res, error.message || '批量报废预检失败', error.status || 422) + } +} + +export const createBatchDisposal = async (req: Request, res: Response) => { + try { + const userId = getCurrentUser(res).id + if (!isEquipmentId(userId)) return errorResponse(res, '用户未登录', 401) + const { assets } = req.body || {} + if (!Array.isArray(assets) || assets.length < 1 || assets.length > MAX_DISPOSAL_BATCH_ROOTS) { + return errorResponse(res, `每批须包含 1 至 ${MAX_DISPOSAL_BATCH_ROOTS} 个主资产`, 422) + } + const input = readDisposalSubmission(req.body) + const rootIds: number[] = [] + const confirmed = new Map() + const lockedIds = new Set() + for (const asset of assets) { + if (!isEquipmentId(asset?.equipment_id) || !Array.isArray(asset.item_ids) || + !asset.item_ids.every(isEquipmentId) || !asset.item_ids.includes(asset.equipment_id)) { + return errorResponse(res, '请逐项确认报废明细,明细必须包含主资产', 422) + } + if (confirmed.has(asset.equipment_id)) return errorResponse(res, '批量报废包含重复主资产', 422) + for (const id of asset.item_ids as number[]) { + if (lockedIds.has(id)) return errorResponse(res, '批量报废明细包含重复资产', 422) + lockedIds.add(id) + } + rootIds.push(asset.equipment_id) + confirmed.set(asset.equipment_id, asset.item_ids) + } + + const disposalIds = await prisma.$transaction(async tx => { + // All confirmed assets are locked in ID order. Re-read the current trees + // after locking; any unconfirmed descendant aborts before the first write. + await lockEquipmentRows(tx, [...lockedIds]) + const roots = await tx.equipment.findMany({ where: { id: { in: rootIds } }, select: disposalTreeSelect }) + if (roots.length !== rootIds.length) throw new DisposalConflictError('有主资产已不存在,请重新预检') + const trees = await loadDisposalTrees(tx, roots) + for (const id of rootIds) { + const tree = trees.get(id)! + const blocked = getDisposalBlockedReason(tree) + if (blocked) throw new DisposalConflictError(`${tree[0].equipment_code}:${blocked}`) + assertDisposalItemsConfirmed(tree, confirmed.get(id)!) + } + const occupancy = await getDisposalOccupancy(tx, [...lockedIds]) + for (const id of rootIds) { + const tree = trees.get(id)! + const blocked = getDisposalBlockedReason(tree, occupancy) + if (blocked) throw new DisposalConflictError(`${tree[0].equipment_code}:${blocked}`) + } + + const created = [] + for (const id of rootIds) created.push(await writeDisposalSubmission(tx, trees.get(id)!, userId, input)) + const codes = rootIds.map(id => trees.get(id)![0].equipment_code) + const ids = created.map(row => row.id) + const userName = res.locals.user?.username || res.locals.user?.name || 'unknown' + await tx.operationLog.createMany({ data: codes.map((code, index) => ({ + user_id: userId, username: userName, operation_type: '报废申请', + operation_details: `申请报废资产 ${code},原因:${input.reason}`, + equipment_id: rootIds[index], equipment_code: code, ip_address: req.ip || null + })) }) + const recipients = await getSubmissionRecipients(tx, userId) + const preview = codes.slice(0, 10).join('、') + (codes.length > 10 ? ` 等 ${codes.length} 个` : '') + await createBusinessNotification(tx, { + ...recipients, eventType: 'disposal_applied', businessType: 'disposal', businessId: ids[0], + title: `待审批报废:${ids.length} 笔`, + content: `${res.locals.user?.name || userName} 提交了 ${ids.length} 笔报废申请:${preview}`, + level: 'warning', actionUrl: '/workflows/disposals', + metadata: { disposal_ids: ids, equipment_codes: codes, count: ids.length }, + dingTalk: { + title: '批量报废待审批', actionTitle: '查看待审批', messageVersion: 'v1', + content: [ + `**申请时间:** ${formatBeijingDateTime(created[0].applied_at)}`, + `**待审批:** ${ids.length} 笔(${escapeNotificationMarkdownInline(preview)})`, + `**申请人:** ${escapeNotificationMarkdownInline(res.locals.user?.name || userName)}`, + `**报废原因:** ${escapeNotificationMarkdownInline(input.reason.slice(0, 300))}`, + '> 请在报废流程列表中逐笔核对并审批。' + ].join('\n\n') + } + }) + return ids + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, timeout: 30000 }) + successResponse(res, { count: disposalIds.length, disposal_ids: disposalIds }, '批量报废申请创建成功') + } catch (error: any) { + console.error('[批量创建报废申请失败]', error?.message || error) + errorResponse(res, error.message || '批量创建报废申请失败', error.status || 422) + } +} + export const getDisposalList = async (req: Request, res: Response) => { try { const { page = 1, pageSize = 10, status, keyword, equipment_id, scope } = req.query diff --git a/backend/src/routes/disposal.ts b/backend/src/routes/disposal.ts index f28c384..44d6d56 100644 --- a/backend/src/routes/disposal.ts +++ b/backend/src/routes/disposal.ts @@ -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', diff --git a/backend/src/services/__tests__/hrSyncService.test.ts b/backend/src/services/__tests__/hrSyncService.test.ts index 101ad78..115d789 100644 --- a/backend/src/services/__tests__/hrSyncService.test.ts +++ b/backend/src/services/__tests__/hrSyncService.test.ts @@ -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 diff --git a/backend/src/services/hrSyncService.ts b/backend/src/services/hrSyncService.ts index 39ef6a7..f82ec04 100644 --- a/backend/src/services/hrSyncService.ts +++ b/backend/src/services/hrSyncService.ts @@ -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 diff --git a/backend/src/utils/disposalSubmission.ts b/backend/src/utils/disposalSubmission.ts new file mode 100644 index 0000000..6e80185 --- /dev/null +++ b/backend/src/utils/disposalSubmission.ts @@ -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() + 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>) => { + 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('、')} 仍处于挂载状态;请先卸载未勾选配件或重新核对后提交` + ) +} diff --git a/docker-compose.yml b/docker-compose.yml index afacbfb..5593a22 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-供应商,快递公司,外部交流} diff --git a/frontend/src/api/disposal.ts b/frontend/src/api/disposal.ts index 41ec2c6..5aae745 100644 --- a/frontend/src/api/disposal.ts +++ b/frontend/src/api/disposal.ts @@ -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>('/disposal/batch/preview', data) + .then((response) => response.data) + +export const createBatchDisposal = (data: CreateBatchDisposalParams) => request + .post>('/disposal/batch', data, { timeout: 60000 }) + .then((response) => response.data) + export const returnEquipmentBeforeDisposal = (equipmentId: number, data: { return_condition: '正常' | '损坏' confirmation_token?: string diff --git a/frontend/src/components/BatchDisposalDialog.vue b/frontend/src/components/BatchDisposalDialog.vue new file mode 100644 index 0000000..cce2906 --- /dev/null +++ b/frontend/src/components/BatchDisposalDialog.vue @@ -0,0 +1,218 @@ + + + diff --git a/frontend/src/utils/disposalImport.ts b/frontend/src/utils/disposalImport.ts new file mode 100644 index 0000000..b3a01a4 --- /dev/null +++ b/frontend/src/utils/disposalImport.ts @@ -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(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 +} diff --git a/frontend/src/views/equipment/EquipmentList.vue b/frontend/src/views/equipment/EquipmentList.vue index 195a401..6ef0ebb 100644 --- a/frontend/src/views/equipment/EquipmentList.vue +++ b/frontend/src/views/equipment/EquipmentList.vue @@ -76,7 +76,7 @@ -
+
+ +
批量借出 +
@@ -751,6 +760,13 @@ + + 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([]) const total = ref(0) const currentPage = ref(1) @@ -866,6 +883,8 @@ const barcodePrintIds = ref([]) const showBatchBorrowModal = ref(false) const showBatchReturnModal = ref(false) const showBatchTransferModal = ref(false) +const batchDisposalIds = ref(null) +const batchDisposalAppliedCount = ref(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) ||