Files
KCGL/inventory-web/src/views/operation/scrap/approval/index.vue
yueli c999aac1e3 fix(scrap): 撤回按钮改用 scrap_approval 权限,修正错配
报废审批页的「撤回」按钮原先复用了执行按钮的 canExecute 条件
(scrap_execute)。但 scrap_execute 比 scrap_approval 多授予 INBOUND 角色 ——
意味着只具备执行权、无审批权的人也会看到撤回按钮。

撤回是对单据的处置动作,应与审批同权限等级。新增 canWithdraw 计算属性
(SUPER_ADMIN 或 scrap_approval),替换原 canExecute。

注:申请人在「我的申请单」页有自己的撤回入口,走的是单据归属校验,
不依赖此处权限。
2026-09-10 15:37:17 +08:00

337 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="app-container">
<!-- 顶部工具栏 -->
<div class="filter-container">
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="0">待审批</el-radio-button>
<el-radio-button :label="1">已通过</el-radio-button>
<el-radio-button :label="2">已驳回</el-radio-button>
<el-radio-button :label="3">已执行</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
<el-radio-group v-model="scope" size="default" style="margin-left:12px;" @change="handleStatusChange">
<el-radio-button label="pending">待我审批</el-radio-button>
<el-radio-button label="executable">待执行</el-radio-button>
<el-radio-button label="mine">我提交的</el-radio-button>
<el-radio-button label="all">全部单</el-radio-button>
</el-radio-group>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="list"
border
stripe
style="margin-top: 16px;"
row-key="id"
:expand-row-keys="expandedRows"
@expand-change="handleExpandChange"
>
<!-- 展开行:本次报废对应的“选单内容”(冻结库存行) -->
<el-table-column type="expand" width="60" align="center">
<template #default="{ row }">
<div style="padding: 12px 24px; background: #f5f7fa;">
<p style="margin: 0 0 10px 0; font-weight: bold; font-size: 13px; color: #606266;">
报废明细(共 {{ row.items?.length || 0 }} 项)
</p>
<el-table v-if="row.items?.length" :data="row.items" border size="small" style="width: 100%;">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="来源" width="90" align="center">
<template #default="{ row: item }">
<el-tag size="small">{{ sourceLabel(item.source_table) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
<el-table-column prop="location" label="库位" width="120" show-overflow-tooltip />
<el-table-column prop="batch_number" label="批次/序列号" width="140" show-overflow-tooltip />
<el-table-column label="报废数量" width="100" align="center">
<template #default="{ row: item }">
<span style="color: #F56C6C; font-weight: bold;">{{ item.scrap_qty ?? '-' }}</span>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无报废明细" :image-size="60" />
</div>
</template>
</el-table-column>
<el-table-column prop="request_no" label="申请单号" width="200">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="toggleExpand(row)">
{{ row.request_no }}
</el-link>
</template>
</el-table-column>
<el-table-column label="申请人" width="140">
<template #default="{ row }">{{ row.applicant_name || getApplicantName(row.applicant_id) }}</template>
</el-table-column>
<el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
<el-table-column label="报废种类" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.items?.length || 0 }} 项</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="170" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="审批信息" width="180">
<template #default="{ row }">
<template v-if="row.status === 1 || row.status === 3">
<span style="color: #67C23A;">{{ row.actual_approver_name || getApproverName(row.actual_approver_id) }}</span>
<br />
<span style="font-size: 12px; color: #909399;">{{ row.approved_at || '' }}</span>
</template>
<template v-else-if="row.status === 2">
<span style="color: #F56C6C;">已驳回</span>
<el-tooltip v-if="row.reject_reason" :content="row.reject_reason" placement="top">
<el-icon style="margin-left: 4px; cursor: pointer;"><Warning /></el-icon>
</el-tooltip>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<template v-if="row.status === 0">
<el-button
v-if="userStore.hasPermission('scrap_approval:operation') || userStore.role === 'SUPER_ADMIN'"
type="success" size="small" :loading="row._approving" @click="handleApprove(row)"
>通过</el-button>
<el-button
v-if="userStore.hasPermission('scrap_approval:operation') || userStore.role === 'SUPER_ADMIN'"
type="danger" size="small" :loading="row._approving" @click="openRejectDialog(row)"
>驳回</el-button>
</template>
<!-- ★ 已通过(待执行):执行报废 / 撤回作废 -->
<template v-else-if="row.status === 1">
<el-button
v-if="canExecute"
type="warning" plain size="small" :loading="row._executing" @click="openExecute(row)"
>执行报废</el-button>
<el-button
v-if="canWithdraw"
type="info" plain size="small" :loading="row._withdrawing" @click="handleWithdraw(row)"
>撤回</el-button>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
background
style="margin-top: 16px; justify-content: flex-end; display: flex;"
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
<!-- 驳回原因 Dialog -->
<el-dialog v-model="rejectDialogVisible" title="驳回报废申请" width="480px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
</el-form-item>
<el-form-item label="驳回原因" required>
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请填写驳回原因" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="rejectLoading" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Refresh, Warning } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getScrapApprovals, approveScrapRequest, withdrawScrapRequest } from '@/api/scrap'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
// --- 状态 ---
const list = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
const scope = ref<'pending' | 'executable' | 'mine' | 'all'>('mine')
const expandedRows = ref<string[]>([])
// 驳回 Dialog
const rejectDialogVisible = ref(false)
const currentRejectRow = ref<any>(null)
const rejectReason = ref('')
const rejectLoading = ref(false)
const userNameCache = ref<Record<number, string>>({})
// --- 权限 ---
// 执行报废:需「按单报废执行」权限
const canExecute = computed(() =>
userStore.role === 'SUPER_ADMIN' || userStore.hasPermission('scrap_execute')
)
// ★ 撤回:需「报废审批」权限(原先误用 canExecute)
//
// scrap_execute 比 scrap_approval 多授予 INBOUND 角色 —— 若沿用 canExecute,
// 只具备执行权、无审批权的人也会看到撤回按钮。撤回是对单据的处置动作,
// 应与审批同权限等级。(申请人在「申请页-我的申请单」有自己的撤回入口,
// 走的是单据归属校验,不依赖此处。)
const canWithdraw = computed(() =>
userStore.role === 'SUPER_ADMIN' || userStore.hasPermission('scrap_approval')
)
// --- 工具 ---
const statusText = (status: number) => {
const map: Record<number, string> = { 0: '待审批', 1: '已通过', 2: '已驳回', 3: '已执行', 4: '已撤回' }
return map[status] ?? '-'
}
const statusTagType = (status: number) => {
const map: Record<number, string> = { 0: 'warning', 1: 'success', 2: 'danger', 3: 'info', 4: 'info' }
return map[status] ?? 'info'
}
const sourceLabel = (st: string) => ({ stock_buy: '采购件', stock_semi: '半成品', stock_product: '成品' } as any)[st] || st || '-'
const getApplicantName = (id: number | null) => (id ? (userNameCache.value[id] ?? `用户 #${id}`) : '-')
const getApproverName = (id: number | null) => (id ? (userNameCache.value[id] ?? `用户 #${id}`) : '-')
// --- 展开行 ---
const toggleExpand = (row: any) => {
const idx = expandedRows.value.indexOf(row.id)
if (idx > -1) expandedRows.value.splice(idx, 1)
else expandedRows.value.push(row.id)
}
const handleExpandChange = () => { /* 由 expandedRows 控制 */ }
// --- 数据获取 ---
const fetchData = async () => {
loading.value = true
try {
const params: any = { page: page.value, limit: pageSize.value, scope: scope.value }
if (filterStatus.value !== '') params.status = filterStatus.value
const res: any = await getScrapApprovals(params)
const records = res.data?.items || []
records.forEach((r: any) => {
if (r.applicant_id && r.applicant_name) userNameCache.value[r.applicant_id] = r.applicant_name
if (r.actual_approver_id && r.actual_approver_name) userNameCache.value[r.actual_approver_id] = r.actual_approver_name
r._approving = false
r._executing = false
r._withdrawing = false
})
list.value = records
total.value = res.data?.total || records.length || 0
} catch (err: any) {
ElMessage.error(err?.msg || '加载报废审批列表失败')
} finally {
loading.value = false
}
}
// --- 筛选 / 分页 ---
const handleStatusChange = () => { page.value = 1; expandedRows.value = []; fetchData() }
const handlePageChange = (p: number) => { page.value = p; fetchData() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchData() }
// --- 审批操作 ---
const handleApprove = async (row: any) => {
try {
await ElMessageBox.confirm(`确定要通过报废申请单 【${row.request_no}】 吗?`, '审批确认',
{ confirmButtonText: '确定通过', cancelButtonText: '取消', type: 'info' })
} catch { return }
row._approving = true
try {
await approveScrapRequest(row.id, { action: 'approve' })
ElMessage.success(`申请单 ${row.request_no} 已通过`)
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '审批操作失败')
} finally { row._approving = false }
}
const openRejectDialog = (row: any) => {
currentRejectRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmReject = async () => {
const reason = rejectReason.value.trim()
if (!reason) return ElMessage.warning('请填写驳回原因')
rejectLoading.value = true
try {
await approveScrapRequest(currentRejectRow.value.id, { action: 'reject', reject_reason: reason })
ElMessage.success(`申请单 ${currentRejectRow.value.request_no} 已驳回`)
rejectDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '驳回操作失败')
} finally { rejectLoading.value = false }
}
// --- 按单报废执行:跳转到「按单报废执行」扫码页,由实扫明细驱动扣减 ---
const openExecute = (row: any) => {
router.push({ path: '/scrap/create', query: { requestId: row.id } })
}
// ★ 撤回已通过但尚未执行的报废申请
// 作废该单;若报废已接入库存预占,后端会一并释放预占量
const handleWithdraw = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定撤回报废申请【${row.request_no}】吗?\n\n` +
`撤回后该单将作废,其占用的物料库存会被释放,可供其它单据使用。\n` +
`此操作不可恢复。`,
'⚠️ 撤回确认',
{ confirmButtonText: '确认撤回', cancelButtonText: '取消', type: 'warning' }
)
} catch (e) {
return
}
row._withdrawing = true
try {
await withdrawScrapRequest(row.id)
ElMessage.success(`报废申请 ${row.request_no} 已撤回`)
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '撤回失败')
} finally {
row._withdrawing = false
}
}
onMounted(() => { fetchData() })
</script>
<style scoped>
.app-container { padding: 20px; }
.filter-container { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
</style>