From 3e7486e760b9c042a152b70edc79d7acc712388b Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 21 Jul 2026 18:45:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=8C=E6=94=BF=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=8F=91=E8=B5=B7=E6=8A=A5=E5=BA=9F=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../disposalController.state-machine.test.ts | 58 +++++++++++++++++++ .../equipmentController.update-status.test.ts | 3 + backend/src/controllers/disposalController.ts | 27 ++++++--- .../src/utils/__tests__/permissions.test.ts | 3 +- backend/src/utils/permissions.ts | 1 + .../components/DisposalManagement.vue | 15 +++-- .../src/views/workflows/DisposalDetail.vue | 8 ++- 7 files changed, 99 insertions(+), 16 deletions(-) diff --git a/backend/src/controllers/__tests__/disposalController.state-machine.test.ts b/backend/src/controllers/__tests__/disposalController.state-machine.test.ts index 1a30223..4e280a9 100644 --- a/backend/src/controllers/__tests__/disposalController.state-machine.test.ts +++ b/backend/src/controllers/__tests__/disposalController.state-machine.test.ts @@ -290,6 +290,35 @@ test('并发审批 CAS 失败时返回 409,不追加动作也不改资产', as } }) +test('行政不能审批自己发起的报废申请', async () => { + const originalTransaction = prisma.$transaction + let updateCount = 0 + + ;(prisma.$transaction as any) = async (callback: any) => callback({ + disposalRecord: { + findUnique: async () => ({ id: 94, applied_by: 6, items: [] }), + updateMany: async () => { + updateCount += 1 + return { count: 1 } + } + } + }) + + try { + const { res, state } = createResponse('administrative') + await approveDisposal( + { params: { id: '94' }, body: { approved: true } } as any, + res as any + ) + + assert.equal(state.statusCode, 403) + assert.match(state.body.message, /不能审批自己发起/) + assert.equal(updateCount, 0) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) + test('驳回报废时按每个 item.pre_status 分别恢复', async () => { const originalTransaction = prisma.$transaction const originalLogCreate = prisma.operationLog.create @@ -393,6 +422,35 @@ test('行政已处理列表包含全部已审批、已驳回和已完成记录 } }) +test('行政可以查询自己发起的报废流程', async () => { + const originalFindMany = prisma.disposalRecord.findMany + const originalCount = prisma.disposalRecord.count + const originalGroupBy = prisma.disposalRecord.groupBy + let capturedWhere: any = null + + ;(prisma.disposalRecord.findMany as any) = async (args: any) => { + capturedWhere = args.where + return [] + } + ;(prisma.disposalRecord.count as any) = async () => 0 + ;(prisma.disposalRecord.groupBy as any) = async () => [] + + try { + const { res, state } = createResponse('administrative') + await getDisposalList( + { query: { scope: 'mine', page: '1', pageSize: '10' } } as any, + res as any + ) + + assert.equal(state.statusCode, 200) + assert.deepEqual(capturedWhere.AND[0], { applied_by: 6 }) + } finally { + ;(prisma.disposalRecord.findMany as any) = originalFindMany + ;(prisma.disposalRecord.count as any) = originalCount + ;(prisma.disposalRecord.groupBy as any) = originalGroupBy + } +}) + test('IT即使请求all范围也只能查询本人发起的报废记录', async () => { const originalFindMany = prisma.disposalRecord.findMany const originalCount = prisma.disposalRecord.count diff --git a/backend/src/controllers/__tests__/equipmentController.update-status.test.ts b/backend/src/controllers/__tests__/equipmentController.update-status.test.ts index 8c55cf1..dde7030 100644 --- a/backend/src/controllers/__tests__/equipmentController.update-status.test.ts +++ b/backend/src/controllers/__tests__/equipmentController.update-status.test.ts @@ -57,6 +57,7 @@ test('普通编辑资产时不应把待报废资产冲回可用', async () => { ;(prisma.equipment.findUnique as any) = async () => currentEquipment ;(prisma.$transaction as any) = async (callback: any) => callback({ $queryRawUnsafe: async () => [], + operationLog: { create: async () => ({ id: 1 }) }, equipment: { updateMany: async (args: any) => { capturedUpdateData = args.data @@ -177,6 +178,7 @@ test('行政纠错只写入本次提交的基础字段', async () => { ;(prisma.department.findFirst as any) = async () => ({ id: 7, name: '行政部' }) ;(prisma.$transaction as any) = async (callback: any) => callback({ $queryRawUnsafe: async () => [], + operationLog: { create: async () => ({ id: 1 }) }, borrowRecord: { count: async () => 0 }, maintenanceRecord: { count: async () => 0 }, disposalItem: { count: async () => 0 }, @@ -268,6 +270,7 @@ test('修改资产类型名称时同步更新 asset_type_id', async () => { ;(prisma.assetType.findFirst as any) = async () => newType ;(prisma.$transaction as any) = async (callback: any) => callback({ $queryRawUnsafe: async () => [], + operationLog: { create: async () => ({ id: 1 }) }, assetType: { findUnique: async () => newType }, equipment: { count: async () => 0, diff --git a/backend/src/controllers/disposalController.ts b/backend/src/controllers/disposalController.ts index f151a1f..a002244 100644 --- a/backend/src/controllers/disposalController.ts +++ b/backend/src/controllers/disposalController.ts @@ -98,17 +98,22 @@ const updateEquipmentLifecycle = async ( } } -const getSubmissionRecipients = async (tx: Prisma.TransactionClient) => { +const excludeApplicant = (recipients: T[], applicantId: number) => + recipients.filter((recipient) => recipient.id !== applicantId) + +const getSubmissionRecipients = async (tx: Prisma.TransactionClient, applicantId: number) => { const rollout = getCurrentDingTalkRecipientRollout() const [stationAdministrative, dingTalkAdministrative] = await Promise.all([ findActiveStationUsersByRoles(tx, ['administrative']), findActiveDingTalkUsersByRoles(tx, ['administrative'], rollout) ]) - const stationRecipients = stationAdministrative.length > 0 - ? stationAdministrative + const eligibleStationAdministrative = excludeApplicant(stationAdministrative, applicantId) + const eligibleDingTalkAdministrative = excludeApplicant(dingTalkAdministrative, applicantId) + const stationRecipients = eligibleStationAdministrative.length > 0 + ? eligibleStationAdministrative : await findActiveStationUsersByRoles(tx, ['admin']) - const dingTalkRecipients = dingTalkAdministrative.length > 0 - ? dingTalkAdministrative + const dingTalkRecipients = eligibleDingTalkAdministrative.length > 0 + ? eligibleDingTalkAdministrative : await findActiveDingTalkUsersByRoles(tx, ['admin'], rollout) return { stationRecipients, dingTalkRecipients } } @@ -352,7 +357,7 @@ export const createDisposal = async (req: Request, res: Response) => { await updateEquipmentLifecycle(tx, item, '待报废', item.status) } - const recipients = await getSubmissionRecipients(tx) + const recipients = await getSubmissionRecipients(tx, userId) await createBusinessNotification(tx, { eventType: 'disposal_applied', businessType: 'disposal', @@ -425,12 +430,14 @@ export const getDisposalList = async (req: Request, res: Response) => { if (currentUser.role === 'it') { summaryConditions.push({ applied_by: currentUser.id }) } else if (currentUser.role === 'administrative') { - if (resolvedScope === 'pending') { + if (resolvedScope === 'mine') { + summaryConditions.push({ applied_by: currentUser.id }) + } else if (resolvedScope === 'pending') { summaryConditions.push({ status: 'pending' }) } else if (resolvedScope === 'processed') { summaryConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } }) } else { - return errorResponse(res, '行政仅可查看全部待审批和全部已处理报废流程', 403) + return errorResponse(res, '行政仅可查看本人发起、全部待审批和全部已处理报废流程', 403) } } else if (currentUser.role === 'admin') { if (resolvedScope === 'mine') { @@ -556,6 +563,10 @@ export const approveDisposal = async (req: Request, res: Response) => { include: { items: { include: { equipment: true } } } }) if (!disposal) throw Object.assign(new Error('报废记录不存在'), { status: 404 }) + const currentUser = getCurrentUser(res) + if (currentUser.role === 'administrative' && disposal.applied_by === userId) { + throw Object.assign(new Error('行政不能审批自己发起的报废申请,请由其他行政或管理员处理'), { status: 403 }) + } const changedItem = disposal.items.find((item) => item.equipment.status !== '待报废' || item.equipment.parent_id !== item.parent_equipment_id diff --git a/backend/src/utils/__tests__/permissions.test.ts b/backend/src/utils/__tests__/permissions.test.ts index 19e8308..fdeaadf 100644 --- a/backend/src/utils/__tests__/permissions.test.ts +++ b/backend/src/utils/__tests__/permissions.test.ts @@ -10,11 +10,12 @@ test('管理员拥有完整权限集合', () => { assert.ok(permissions.includes(PERMISSIONS.ASSETS_VIEW_OWN)) assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS)) }) -test('行政可以登记和审批但不能借还、调拨或挂载', () => { +test('行政可以登记、发起报废和审批,但不能借还、调拨或挂载', () => { const permissions = getPermissionsForRole('administrative') assert.ok(permissions.includes(PERMISSIONS.ASSETS_CREATE)) assert.ok(permissions.includes(PERMISSIONS.ASSETS_CORRECT)) assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS)) + assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY)) assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPROVE)) assert.ok(!permissions.includes(PERMISSIONS.ASSETS_BORROW)) assert.ok(!permissions.includes(PERMISSIONS.ASSETS_TRANSFER)) diff --git a/backend/src/utils/permissions.ts b/backend/src/utils/permissions.ts index 4dc7539..80c7184 100644 --- a/backend/src/utils/permissions.ts +++ b/backend/src/utils/permissions.ts @@ -37,6 +37,7 @@ const ROLE_PERMISSIONS: Record = { PERMISSIONS.ASSETS_IMPORT, PERMISSIONS.ASSETS_CORRECT, PERMISSIONS.ASSETS_PRINT_LABELS, + PERMISSIONS.DISPOSALS_APPLY, PERMISSIONS.DISPOSALS_APPROVE, PERMISSIONS.DISPOSALS_COMPLETE, PERMISSIONS.INVENTORY_MANAGE, diff --git a/frontend/src/views/settings/components/DisposalManagement.vue b/frontend/src/views/settings/components/DisposalManagement.vue index 9288c2c..0cfb8b8 100644 --- a/frontend/src/views/settings/components/DisposalManagement.vue +++ b/frontend/src/views/settings/components/DisposalManagement.vue @@ -145,7 +145,7 @@
@@ -159,8 +159,12 @@ const completeForm = ref({ actual_value: 0, completion_notes: '' }) const canApprove = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPROVE)) const canComplete = computed(() => authStore.can(PERMISSIONS.DISPOSALS_COMPLETE)) +const canApproveRecord = computed(() => !( + !canApprove.value || + (authStore.user?.role === 'administrative' && Number(record.value?.applied_by) === Number(authStore.user?.id)) +)) const showActions = computed(() => - (record.value?.status === 'pending' && canApprove.value) || + (record.value?.status === 'pending' && canApproveRecord.value) || (record.value?.status === 'approved' && canComplete.value) ) const disposalItems = computed(() => {