feat: 行政支持发起报废流程

This commit is contained in:
WuYongJie 2026-07-21 18:45:54 +08:00
parent f31924cc1e
commit 3e7486e760
7 changed files with 99 additions and 16 deletions

View File

@ -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

View File

@ -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,

View File

@ -98,17 +98,22 @@ const updateEquipmentLifecycle = async (
}
}
const getSubmissionRecipients = async (tx: Prisma.TransactionClient) => {
const excludeApplicant = <T extends { id: number }>(recipients: T[], applicantId: number) =>
recipients.filter((recipient) => recipient.id !== applicantId)
const getSubmissionRecipients = async (tx: Prisma.TransactionClient, applicantId: number) => {
const rollout = getCurrentDingTalkRecipientRollout()
const [stationAdministrative, dingTalkAdministrative] = await Promise.all([
findActiveStationUsersByRoles(tx, ['administrative']),
findActiveDingTalkUsersByRoles(tx, ['administrative'], rollout)
])
const 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

View File

@ -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))

View File

@ -37,6 +37,7 @@ const ROLE_PERMISSIONS: Record<UserRole, readonly Permission[]> = {
PERMISSIONS.ASSETS_IMPORT,
PERMISSIONS.ASSETS_CORRECT,
PERMISSIONS.ASSETS_PRINT_LABELS,
PERMISSIONS.DISPOSALS_APPLY,
PERMISSIONS.DISPOSALS_APPROVE,
PERMISSIONS.DISPOSALS_COMPLETE,
PERMISSIONS.INVENTORY_MANAGE,

View File

@ -145,7 +145,7 @@
<div class="mt-4 flex gap-2">
<button
v-if="item.status === 'pending' && canApprove"
v-if="item.status === 'pending' && canApproveItem(item)"
class="btn-primary text-xs sm:text-sm"
@click="handleApprove(item, true)"
>
@ -214,14 +214,14 @@
<div class="table-action-group justify-end">
<button class="table-action" @click="openDetail(item)">详情</button>
<button
v-if="item.status === 'pending' && canApprove"
v-if="item.status === 'pending' && canApproveItem(item)"
class="table-action table-action--primary"
@click="handleApprove(item, true)"
>
通过
</button>
<button
v-if="item.status === 'pending' && canApprove"
v-if="item.status === 'pending' && canApproveItem(item)"
class="table-action table-action--danger"
@click="handleApprove(item, false)"
>
@ -266,7 +266,7 @@
>
<button type="button" class="btn-secondary w-full" @click="handleMobileOpenDetail">查看详情</button>
<button
v-if="mobileActionItem?.status === 'pending' && canApprove"
v-if="mobileActionItem?.status === 'pending' && canApproveItem(mobileActionItem)"
type="button"
class="btn-secondary w-full !text-status-error"
@click="handleMobileReject"
@ -412,7 +412,12 @@ const changeScope = (scope: DisposalScope) => {
const openDetail = (item: DisposalRecord) => router.push(`/workflows/disposals/${item.id}`)
const hasMobileSecondaryActions = (item: DisposalRecord) =>
(item.status === 'pending' && canApprove.value) || (item.status === 'approved' && canComplete.value)
(item.status === 'pending' && canApproveItem(item)) || (item.status === 'approved' && canComplete.value)
const canApproveItem = (item: DisposalRecord) => !(
!canApprove.value ||
(authStore.user?.role === 'administrative' && Number(item.applied_by) === Number(authStore.user?.id))
)
const handleMobileOpenDetail = () => {
const item = mobileActionItem.value

View File

@ -104,7 +104,7 @@
<section v-if="showActions" class="card">
<h3 class="section-title">处理申请</h3>
<div v-if="record.status === 'pending' && canApprove" class="mt-4 grid gap-4 md:grid-cols-2">
<div v-if="record.status === 'pending' && canApproveRecord" class="mt-4 grid gap-4 md:grid-cols-2">
<button type="button" class="btn-primary" :disabled="submitting" @click="submitApproval(true)">审批通过</button>
<div class="field-stack">
<label class="field-label">驳回原因</label>
@ -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<DisposalItem[]>(() => {