feat: 行政支持发起报废流程
This commit is contained in:
parent
f31924cc1e
commit
3e7486e760
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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[]>(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user