Files
bj_power/bj_power_wms/frontend/src/pages/Inspection.vue
T
SunYF 4a5e2d7bac feat: 完成多模块迭代更新
- 新增过程巡检按步骤上报、数量不符上报、工位退料功能
- 增加工单工程编号三级归属字段
- 新增物料安全库存与缺货预警
- 新增附件管理、退库单管理模块
- 优化生产看板展示逻辑与前端页面文案
- 清理冗余的工艺路线相关代码与备份文件
2026-09-15 16:37:39 +08:00

578 lines
26 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.
<script setup>
defineOptions({ name: 'Inspection' })
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import request from '../utils/request'
import { getRealName } from '../utils/auth'
import { fmtTime } from '../utils/format'
import { exportXlsxFetch } from '../utils/export'
import { can } from '../utils/perm'
import PageHelp from '../components/PageHelp.vue'
import AttachmentPanel from '../components/AttachmentPanel.vue'
import { helpInspection } from '../help'
// ===== 多 Tab:录入在前默认打开,记录在后点击才见(参照出库管理) =====
const activeTab = ref('entry') // entry=质量检验(录入) / records=检验记录
// 菜单按业务分开(来料/过程/完工检验):从路由 meta 锁定检验类型,同表同接口按类别过滤
const route = useRoute()
const pageType = String(route.meta.inspectionType || '')
/* ===================== Tab1 质量检验(录入,默认打开) ===================== */
const targetType = ref('BATCH')
const status = ref('合格')
const inspector = ref(getRealName())
const submitting = ref(false)
// 检验量化字段
const inspectQty = ref(0)
const passQty = ref(0)
const checkDesc = ref('')
const inspectionNo = ref('')
const reportNo = ref('')
// 不合格品处置(客户诉求 问题记录 L137-145:退货 / 报废 / 部分入库)
const disposalType = ref('NONE')
const disposalRemark = ref('')
const returnTrackingNo = ref('')
// 待检编号列表(合并单/批量,统一一次提交)
const targetList = ref([])
const scanInput = ref('')
const pickerValue = ref('')
// 输入框标题/占位随目标类型变化(而非仅一行小字提示)
const inputLabel = computed(() => (targetType.value === 'BATCH' ? '批次号' : 'SN 序列号'))
const inputPlaceholder = computed(() =>
targetType.value === 'BATCH'
? '扫入或粘贴批次号,可用 逗号/分号/空格/回车 分隔,一次可录多条'
: '扫入或粘贴 SN 序列号,可用 逗号/分号/空格/回车 分隔,一次可录多条'
)
// 库存拾取器:直接选已存在的批次号/SN,避免手输。
// 规范:库存量不确定 → 只加载最近 100 条(created_at desc),输入走远程模糊搜索(keyword 匹配 物料编码/批次号/SN)
const pickerOptions = ref([])
const pickerLoading = ref(false)
async function loadPicker(kw = '') {
pickerLoading.value = true
try {
const mm = targetType.value === 'BATCH' ? 1 : 2
const data = await request.get('/stock/query', {
params: { manageMode: mm, keyword: kw || '', page: 1, pageSize: 100 }
})
pickerOptions.value = data?.list || []
} catch (e) {
pickerOptions.value = []
} finally {
pickerLoading.value = false
}
}
function pickerLabel(opt) {
const id = targetType.value === 'BATCH' ? opt.batchNo : opt.snCode
return `${opt.materialCode || ''}${id || ''}|质量:${opt.qualityStatus || '未检'}`
}
function addFromInput() {
const arr = String(scanInput.value)
.split(/[,.。;;、\s]+/)
.map((s) => s.trim())
.filter(Boolean)
for (const v of arr) {
if (!targetList.value.includes(v)) targetList.value.push(v)
}
scanInput.value = ''
}
function addFromPicker() {
if (!pickerValue.value) return
if (!targetList.value.includes(pickerValue.value)) targetList.value.push(pickerValue.value)
pickerValue.value = ''
}
function removeAt(i) {
targetList.value.splice(i, 1)
}
function clearAll() {
targetList.value = []
}
async function submitInspection() {
if (!targetList.value.length) {
ElMessage.warning('请先录入或选择待检编号')
return
}
if (!inspector.value.trim()) {
ElMessage.warning('请填写检验员')
return
}
// 判定不合格必须选择处置方式(不合格不入库、不上产线)
if (status.value === '不合格' && disposalType.value === 'NONE') {
ElMessage.warning('判定不合格时必须选择处置方式(退货 / 报废 / 部分入库)')
return
}
if (status.value === '不合格' && disposalType.value === 'RETURN' && !returnTrackingNo.value.trim()) {
ElMessage.warning('退货处置必须填写货运单号')
return
}
if (status.value === '不合格' && disposalType.value === 'PARTIAL' && targetList.value.length > 1) {
ElMessage.warning('「部分入库」一次只能处理 1 个批次,请分开提交')
return
}
submitting.value = true
try {
const data = await request.post('/inspection/batch-flip', {
targetType: targetType.value,
targetIds: [...targetList.value],
status: status.value,
inspectQty: Number(inspectQty.value) || 0,
passQty: Number(passQty.value) || 0,
checkDesc: checkDesc.value.trim(),
reportNo: reportNo.value.trim(),
inspector: inspector.value.trim(),
inspectionNo: inspectionNo.value.trim(),
disposalType: status.value === '不合格' ? disposalType.value : 'NONE',
disposalRemark: disposalRemark.value.trim(),
returnTrackingNo: returnTrackingNo.value.trim(),
})
const n = data?.flipped ?? targetList.value.length
ElMessage.success(`检验已记录:共 ${n} 条 → ${status.value}`)
clearAll()
inspectQty.value = 0
passQty.value = 0
checkDesc.value = ''
reportNo.value = ''
disposalType.value = 'NONE'
disposalRemark.value = ''
returnTrackingNo.value = ''
// 提交成功后主动刷新记录 Tab(用户切过去即可见最新)
await loadRecords()
} finally {
submitting.value = false
}
}
/* ===================== 不合格处置(对已判定不合格的记录做退货/报废/部分入库) ===================== */
const disposeDialog = reactive({ visible: false, row: null, type: 'RETURN', remark: '', trackingNo: '' })
function openDispose(row) {
if (row.status !== '不合格') {
ElMessage.warning('仅「不合格」记录可执行处置')
return
}
Object.assign(disposeDialog, { visible: true, row, type: 'RETURN', remark: '', trackingNo: '' })
}
async function submitDispose() {
if (disposeDialog.type === 'RETURN' && !disposeDialog.trackingNo.trim()) {
ElMessage.warning('退货必须填写货运单号')
return
}
await request.post('/inspection/dispose', {
recordId: disposeDialog.row.id,
disposalType: disposeDialog.type,
disposalRemark: disposeDialog.remark.trim(),
returnTrackingNo: disposeDialog.trackingNo.trim(),
})
ElMessage.success('处置完成')
disposeDialog.visible = false
await loadRecords()
}
// 附件抽屉(bizType=inspectionbizId=检验记录ID
const attachDrawer = reactive({ visible: false, id: '', targetId: '' })
function openAttach(row) {
Object.assign(attachDrawer, { visible: true, id: String(row.id), targetId: row.targetId })
}
/* ===================== Tab2 检验记录(点击才加载) ===================== */
// 默认近3个月起始日期
function defaultStart() {
const d = new Date()
d.setMonth(d.getMonth() - 3)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
// 筛选独立条件(不再笼统关键字);默认近3个月
const filters = reactive({
targetId: '', materialCode: '', inspectionType: pageType, status: '', inspector: '',
startDate: defaultStart(), endDate: ''
})
const loading = ref(false)
const statsList = ref([])
const statsLoading = ref(false)
async function loadStats() {
statsLoading.value = true
try {
const data = await request.get('/inspection/stats', {
params: { inspectionType: filters.inspectionType, startDate: filters.startDate, endDate: filters.endDate }
})
statsList.value = data?.list || []
} finally {
statsLoading.value = false
}
}
const list = ref([])
const total = ref(0)
const page = reactive({ current: 1, size: 20 })
function typeLabel(t) {
const m = { incoming: '来料检', process: '过程检', finished: '成品检' }
return m[t] || t || '-'
}
function targetTypeLabel(t) {
return t === 'SN' ? 'SN' : t === 'BATCH' ? '批次' : (t || '-')
}
// 不合格处置方式显示(NONE=未处置)
function disposalLabel(t) {
const m = { RETURN: '退货', SCRAP: '报废', PARTIAL: '部分入库' }
return m[t] || '未处置'
}
async function loadRecords() {
loading.value = true
try {
const data = await request.get('/inspection/query', {
params: {
targetId: filters.targetId.trim(),
materialCode: filters.materialCode.trim(),
inspectionType: filters.inspectionType,
status: filters.status,
inspector: filters.inspector.trim(),
startDate: filters.startDate,
endDate: filters.endDate,
page: page.current,
pageSize: page.size
}
})
list.value = data?.list || []
total.value = data?.total || 0
} finally {
loading.value = false
}
}
function search() {
page.current = 1
loadRecords()
}
// 全量导出按当前筛选条件(无分页),后端生成 .xlsx
async function exportCsv() {
try {
await exportXlsxFetch('/api/inspection/export', {
targetId: filters.targetId.trim(),
materialCode: filters.materialCode.trim(),
inspectionType: filters.inspectionType,
status: filters.status,
inspector: filters.inspector.trim(),
startDate: filters.startDate,
endDate: filters.endDate
}, '检验记录.xlsx')
} catch (e) {
/* 错误提示由请求层统一处理 */
}
}
function onTabChange(tab) {
// 进入记录 Tab 才加载,控制首次进入即查询
if (tab === 'records') loadRecords()
}
// 仅录入时需预载库存拾取器;记录 Tab 懒加载
onMounted(() => {
loadPicker()
})
</script>
<template>
<div>
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<!-- ============ 质量检验录入默认打开 ============ -->
<el-tab-pane label="质量检验录入" name="entry">
<el-card shadow="never" style="max-width:860px;margin:0 auto">
<el-form label-width="96px">
<!-- 目标类型直接改变输入框标题与占位直观体现当前录入对象 -->
<el-form-item label="目标类型">
<el-radio-group v-model="targetType" @change="() => loadPicker()">
<el-radio-button value="BATCH">批次 (结构件)</el-radio-button>
<el-radio-button value="SN">序列号 SN (电气件)</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="检验结论">
<el-radio-group v-model="status">
<el-radio value="合格">合格</el-radio>
<el-radio value="不合格">不合格</el-radio>
</el-radio-group>
</el-form-item>
<!-- 不合格品处置判定不合格时才出现):退货/报废/部分入库 -->
<template v-if="status === '不合格'">
<el-form-item label="处置方式">
<el-radio-group v-model="disposalType">
<el-radio-button value="RETURN">退货</el-radio-button>
<el-radio-button value="SCRAP">报废</el-radio-button>
<el-radio-button value="PARTIAL">部分入库</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="disposalType === 'RETURN'" label="货运单号">
<el-input v-model="returnTrackingNo" placeholder="退货运单号(必填)" clearable style="width:280px" />
</el-form-item>
<el-form-item label="处置说明">
<el-input v-model="disposalRemark" placeholder="报废 / 退货原因(可空)" clearable style="width:420px" />
</el-form-item>
<el-form-item label=" ">
<el-alert type="warning" :closable="false" style="max-width:560px"
title="不合格品自动隔离:库存质量状态置「不合格/待退」,禁止领料上线;退货必填货运单号。" />
</el-form-item>
</template>
<el-form-item label="检验员">
<el-input v-model="inspector" placeholder="检验员姓名" clearable style="width:260px" />
</el-form-item>
<el-form-item label="检测单号">
<el-input v-model="inspectionNo" placeholder="检验报告单号(可空,与入库单关联)" clearable style="width:420px" />
</el-form-item>
<el-form-item label="报检单号">
<el-input v-model="reportNo" placeholder="生产报检单号(可空,与报检流程关联)" clearable style="width:420px" />
</el-form-item>
<el-divider content-position="left">检验量化可空</el-divider>
<el-form-item label="检验数量">
<el-input-number v-model="inspectQty" :min="0" :max="999999" placeholder="抽检件数" style="width:160px" />
</el-form-item>
<el-form-item label="合格数量">
<el-input-number v-model="passQty" :min="0" :max="999999" placeholder="合格件数(≤检验数量)" style="width:160px" />
</el-form-item>
<el-form-item label="检测说明">
<el-input v-model="checkDesc" placeholder="检验项目/方法/标准(可空)" clearable style="width:420px" />
</el-form-item>
<el-divider content-position="left">待检编号可扫/选多条统一提交</el-divider>
<!-- 从库存直接拾取避免手输批次号远程模糊搜索最近 100 -->
<el-form-item :label="`从库存选${inputLabel}`">
<el-select
v-model="pickerValue"
filterable
remote
clearable
:remote-method="loadPicker"
:loading="pickerLoading"
:placeholder="`输入批次号/SN/物料编码搜索(最近100条)`"
style="width:100%"
@change="addFromPicker"
>
<el-option v-for="opt in pickerOptions" :key="targetType === 'BATCH' ? opt.batchNo : opt.snCode"
:value="targetType === 'BATCH' ? opt.batchNo : opt.snCode"
:label="pickerLabel(opt)" />
</el-select>
</el-form-item>
<!-- 批量/扫码录入单条也走这里无需单独的"单个录入" -->
<el-form-item :label="`录入${inputLabel}`">
<div style="width:100%">
<el-input v-model="scanInput" type="textarea" :rows="4"
:placeholder="inputPlaceholder" />
<el-button type="primary" plain style="margin-top:6px" :disabled="!scanInput.trim()"
@click="addFromInput">录入到列表</el-button>
<span style="margin-left:8px;color:#909399;font-size:12px">支持扫码枪/粘贴多编号用逗号分号空格或回车分隔</span>
</div>
</el-form-item>
<!-- 已选清单 -->
<el-form-item label="已选清单">
<div style="width:100%">
<el-tag v-for="(v, i) in targetList" :key="v + i" closable type="info"
style="margin:0 8px 8px 0" @close="removeAt(i)">{{ v }}</el-tag>
<span v-if="!targetList.length" style="color:#909399;font-size:12px">尚未选择任何待检编号</span>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="submitInspection">提交检验</el-button>
<el-button :disabled="!targetList.length" @click="clearAll">清空</el-button>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
<!-- ============ 检验记录点击才加载 ============ -->
<el-tab-pane label="检验记录" name="records">
<el-card shadow="never" class="mb12">
<el-form inline @submit.prevent="search">
<el-form-item label="批次/SN">
<el-input v-model="filters.targetId" placeholder="批次号或 SN" clearable style="width:160px"
@keyup.enter="search" />
</el-form-item>
<el-form-item label="物料编码">
<el-input v-model="filters.materialCode" placeholder="物料编码" clearable style="width:150px"
@keyup.enter="search" />
</el-form-item>
<el-form-item v-if="!pageType" label="检验类型">
<el-select v-model="filters.inspectionType" placeholder="全部" clearable style="width:120px" @change="search">
<el-option label="来料检" value="incoming" />
<el-option label="过程检" value="process" />
<el-option label="成品检" value="finished" />
</el-select>
</el-form-item>
<el-form-item label="结论">
<el-select v-model="filters.status" placeholder="全部" clearable style="width:110px" @change="search">
<el-option label="合格" value="合格" />
<el-option label="不合格" value="不合格" />
<el-option label="未检" value="未检" />
</el-select>
</el-form-item>
<el-form-item label="检验员">
<el-input v-model="filters.inspector" placeholder="检验员" clearable style="width:120px"
@keyup.enter="search" />
</el-form-item>
<el-form-item label="检验时间">
<el-date-picker v-model="filters.startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始"
style="width:130px" @change="search" />
<span style="margin:0 6px"></span>
<el-date-picker v-model="filters.endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束"
style="width:130px" @change="search" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
<el-button v-if="can('inspection:export')" type="success" plain @click="exportCsv">导出</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" header="质量检验记录">
<el-table :data="list" border size="small" v-loading="loading" empty-text="暂无检验记录">
<el-table-column label="ID" prop="id" width="60" align="center" />
<el-table-column label="对象" width="70" align="center">
<template #default="{ row }">{{ targetTypeLabel(row.targetType) }}</template>
</el-table-column>
<el-table-column prop="targetId" label="批次/SN" min-width="150" />
<el-table-column prop="materialCode" label="物料编码" min-width="130" />
<el-table-column label="检验类型" width="90" align="center">
<template #default="{ row }">{{ typeLabel(row.inspectionType) }}</template>
</el-table-column>
<el-table-column label="结论" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === '合格' ? 'success' : row.status === '不合格' ? 'danger' : 'info'"
size="small">{{ row.status || '未检' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="处置方式" width="100" align="center">
<template #default="{ row }">
<span v-if="row.status === '不合格'">{{ disposalLabel(row.disposalType) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="inspectQty" label="检验数" width="80" align="center">
<template #default="{ row }">{{ row.inspectQty || 0 }}</template>
</el-table-column>
<el-table-column prop="passQty" label="合格数" width="80" align="center">
<template #default="{ row }">{{ row.passQty || 0 }}</template>
</el-table-column>
<el-table-column label="检测说明" min-width="140">
<template #default="{ row }">
<el-tooltip v-if="row.checkDesc" :content="row.checkDesc" placement="top">
<span>{{ row.checkDesc }}</span>
</el-tooltip>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="reportNo" label="报检单号" min-width="130" align="center">
<template #default="{ row }">{{ row.reportNo || '-' }}</template>
</el-table-column>
<el-table-column prop="inspector" label="检验员" width="90" align="center">
<template #default="{ row }">{{ row.inspector || '-' }}</template>
</el-table-column>
<el-table-column prop="inspectionNo" label="检测单号" min-width="130" align="center">
<template #default="{ row }">{{ row.inspectionNo || '-' }}</template>
</el-table-column>
<el-table-column label="检验时间" width="155" align="center">
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="130" align="center" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === '不合格'" link type="warning" size="small"
@click="openDispose(row)">处置</el-button>
<el-button link type="success" size="small" @click="openAttach(row)">附件</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<div class="pager">
<el-pagination background layout="total, sizes, prev, pager, next" :total="total"
:current-page="page.current" :page-size="page.size" :page-sizes="[10, 20, 50, 100]"
@current-change="(p) => { page.current = p; loadRecords() }"
@size-change="(s) => { page.size = s; page.current = 1; loadRecords() }" />
</div>
</el-tab-pane>
<!-- ============ 按物料统计(点击才加载) ============ -->
<el-tab-pane label="按物料统计" name="stats">
<el-card shadow="never" header="检验次数 / 合格率按物料">
<div style="margin-bottom:10px">
<el-button type="primary" :loading="statsLoading" @click="loadStats">统计</el-button>
<span style="margin-left:8px;color:#909399;font-size:12px">按当前时间区间与检验类别统计;每次检验独立计数,不合并记录</span>
</div>
<el-table :data="statsList" border size="small" v-loading="statsLoading" empty-text="统计查看结果">
<el-table-column prop="materialCode" label="物料编码" min-width="160" />
<el-table-column prop="total" label="检验次数" width="110" align="center" />
<el-table-column prop="pass" label="合格次数" width="110" align="center" />
<el-table-column prop="fail" label="不合格次数" width="120" align="center" />
<el-table-column label="合格率" width="120" align="center">
<template #default="{ row }">
<span :style="row.passRate >= 100 ? 'color:#67c23a' : 'color:#e6a23c'">{{ row.passRate }}%</span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
<!-- 不合格处置弹窗 -->
<el-dialog v-model="disposeDialog.visible" title="不合格品处置" width="480px">
<el-form label-width="96px">
<el-form-item label="批次/SN">
<el-input :model-value="disposeDialog.row?.targetId || ''" disabled />
</el-form-item>
<el-form-item label="物料编码">
<el-input :model-value="disposeDialog.row?.materialCode || ''" disabled />
</el-form-item>
<el-form-item label="检验数 / 合格数">
<el-input :model-value="(disposeDialog.row?.inspectQty || 0) + ' / ' + (disposeDialog.row?.passQty || 0)"
disabled />
</el-form-item>
<el-form-item label="处置方式" required>
<el-radio-group v-model="disposeDialog.type">
<el-radio-button value="RETURN">退货</el-radio-button>
<el-radio-button value="SCRAP">报废</el-radio-button>
<el-radio-button value="PARTIAL">部分入库</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="disposeDialog.type === 'RETURN'" label="货运单号" required>
<el-input v-model="disposeDialog.trackingNo" placeholder="退货运单号" clearable />
</el-form-item>
<el-form-item label="处置说明">
<el-input v-model="disposeDialog.remark" type="textarea" :rows="2" placeholder="原因可空" />
</el-form-item>
<el-alert v-if="disposeDialog.type === 'PARTIAL'" type="info" :closable="false"
title="部分入库合格数拆分批次合格部分入库差额部分隔离待处置" />
</el-form>
<template #footer>
<el-button @click="disposeDialog.visible = false">取消</el-button>
<el-button type="primary" @click="submitDispose">确认处置</el-button>
</template>
</el-dialog>
<!-- 检验记录附件抽屉 -->
<el-drawer v-model="attachDrawer.visible" :title="`检验附件:${attachDrawer.targetId}`" size="620px">
<AttachmentPanel biz-type="inspection" :biz-id="attachDrawer.id" title="附件检验报告 / 照片" />
</el-drawer>
</div>
<PageHelp :data="helpInspection" page="Inspection" />
</template>
<style scoped>
.mb12 { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
.tip { margin-left: 10px; color: #909399; font-size: 12px; }
</style>