报废审批页的「撤回」按钮原先复用了执行按钮的 canExecute 条件 (scrap_execute)。但 scrap_execute 比 scrap_approval 多授予 INBOUND 角色 —— 意味着只具备执行权、无审批权的人也会看到撤回按钮。 撤回是对单据的处置动作,应与审批同权限等级。新增 canWithdraw 计算属性 (SUPER_ADMIN 或 scrap_approval),替换原 canExecute。 注:申请人在「我的申请单」页有自己的撤回入口,走的是单据归属校验, 不依赖此处权限。
337 lines
14 KiB
Vue
337 lines
14 KiB
Vue
<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>
|