You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

699 lines
22 KiB

<template>
<div class="mod-config">
<el-card class="overview-card" shadow="never">
<div slot="header" class="card-title-row">
<div>
<div class="page-title">任务执行手工补录台账</div>
<div class="page-desc">用于 PLC 信息缺失场景的人工补录确保任务执行链路完整可追溯</div>
</div>
<el-button plain class="add-btn" @click="openDialog">新增补录</el-button>
</div>
<div class="overview-grid">
<div class="overview-item">
<div class="overview-label">补录总数</div>
<div class="overview-value">{{ summary.totalCount }}</div>
</div>
<div class="overview-item">
<div class="overview-label">补录总数量</div>
<div class="overview-value">{{ summary.totalQty }}</div>
</div>
<div class="overview-item">
<div class="overview-label">过站补录</div>
<div class="overview-value">{{ summary.typeCount['过站补录'] || 0 }}</div>
</div>
<div class="overview-item">
<div class="overview-label">上挂补录</div>
<div class="overview-value">{{ summary.typeCount['上挂补录'] || 0 }}</div>
</div>
<div class="overview-item">
<div class="overview-label">下挂补录</div>
<div class="overview-value">{{ summary.typeCount['下挂补录'] || 0 }}</div>
</div>
<div class="overview-item">
<div class="overview-label">完工补录</div>
<div class="overview-value">{{ summary.typeCount['完工补录'] || 0 }}</div>
</div>
</div>
</el-card>
<el-form :inline="true" label-position="top" class="query-form">
<el-form-item label="任务单号">
<el-input v-model.trim="query.jobCode" clearable placeholder="请输入任务单号" style="width: 200px" @keyup.enter.native="loadList" />
</el-form-item>
<el-form-item label="补录类型">
<el-select v-model="query.recordType" clearable placeholder="全部类型" style="width: 180px">
<el-option v-for="item in recordTypeOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="关联单号">
<el-input v-model.trim="query.docNo" clearable placeholder="模糊查询" style="width: 180px" @keyup.enter.native="loadList" />
</el-form-item>
<el-form-item label=" ">
<el-button plain class="search-btn" :loading="loading" @click="loadList">查询</el-button>
<el-button plain class="reset-btn" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-table
class="data-table"
:data="dataList"
border
stripe
style="width: 100%"
v-loading="loading"
:height="tableHeight">
<el-table-column type="index" width="50" />
<el-table-column prop="recordId" label="record_id" width="170" />
<el-table-column label="类型" width="110">
<template slot-scope="scope">
<el-tag size="mini" :type="recordTypeTag(scope.row.recordType)">{{ scope.row.recordType || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="jobId" label="job_id" width="130" />
<el-table-column prop="docNo" label="关联单号" min-width="150" show-overflow-tooltip />
<el-table-column label="挂具" width="100">
<template slot-scope="scope">{{ payloadValue(scope.row, 'rackCode') || '-' }}</template>
</el-table-column>
<el-table-column label="工序" width="120">
<template slot-scope="scope">{{ payloadValue(scope.row, 'stepCode') || '-' }}</template>
</el-table-column>
<el-table-column label="站点" width="120">
<template slot-scope="scope">{{ payloadValue(scope.row, 'stationId') || '-' }}</template>
</el-table-column>
<el-table-column label="缺失原因" min-width="150" show-overflow-tooltip>
<template slot-scope="scope">{{ payloadValue(scope.row, 'missingReason') || '-' }}</template>
</el-table-column>
<el-table-column prop="qty" label="数量" width="80" align="right" />
<el-table-column prop="operatorName" label="补录人" width="110" />
<el-table-column prop="reviewerName" label="复核人" width="110" />
<el-table-column label="补录时间" min-width="170">
<template slot-scope="scope">{{ formatDateTime(scope.row.recordTime) }}</template>
</el-table-column>
</el-table>
<el-dialog
title="PLC缺失信息手工补录"
:visible.sync="dialogVisible"
width="900px"
top="5vh"
class="manual-dialog"
:close-on-click-modal="false"
v-drag>
<el-form ref="manualForm" :model="form" :rules="rules" label-position="top" class="manual-form">
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="补录类型" prop="recordType">
<el-select v-model="form.recordType" style="width: 100%">
<el-option v-for="item in recordTypeOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="10">
<el-form-item label="任务单号" prop="selectedJobCode">
<el-select
v-model="form.selectedJobCode"
filterable
clearable
:loading="jobLoading"
placeholder="请选择任务单号"
style="width: 100%"
@change="onDialogJobChange">
<el-option
v-for="item in jobOptions"
:key="item.jobCode"
:label="`${item.jobCode} / ${item.status || '-'} / ${item.inboundNos || '-'}`"
:value="item.jobCode" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="补录数量" prop="qty">
<el-input-number v-model="form.qty" :min="0" :precision="0" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="任务明细(可选)">
<el-select
v-model="form.selectedDetailKey"
clearable
filterable
:loading="detailLoading"
placeholder="选择后自动带入 batch_id / part_id"
style="width: 100%"
@change="onDetailChange">
<el-option
v-for="item in jobDetailOptions"
:key="item._key"
:label="`${item.inboundNo || '-'} / ${item.partNo || '-'} / 剩余${item.remainingQty || 0}`"
:value="item._key" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="关联单号" prop="docNo">
<el-input v-model.trim="form.docNo" placeholder="建议:任务单+工序+挂具组合号">
<el-button slot="append" @click="fillDocNoByContext">自动生成</el-button>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="8"><el-form-item label="batch_id"><el-input v-model="form.batchId" disabled /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="part_id"><el-input v-model="form.partId" disabled /></el-form-item></el-col>
<el-col :span="8">
<el-form-item label="补录时间">
<el-date-picker v-model="form.recordTime" type="datetime" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="8"><el-form-item label="挂具码"><el-input v-model.trim="form.rackCode" placeholder="例如 RACK-001" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="工序编码"><el-input v-model.trim="form.stepCode" placeholder="例如 POOL-001" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="站点ID"><el-input v-model.trim="form.stationId" placeholder="例如 PLC-POOL-001" /></el-form-item></el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="缺失原因">
<el-select v-model="form.missingReason" style="width: 100%">
<el-option v-for="item in missingReasonOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="原因说明">
<el-input v-model.trim="form.reasonRemark" placeholder="可填写故障说明、补录依据等" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="补录人"><el-input v-model.trim="form.operatorName" placeholder="例如 班组长A" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="复核人"><el-input v-model.trim="form.reviewerName" placeholder="例如 工艺工程师B" /></el-form-item></el-col>
</el-row>
<el-form-item label="扩展JSON(可选)" prop="payloadJson">
<el-input
v-model.trim="form.payloadJson"
type="textarea"
:rows="4"
placeholder='如 {"manualSource":"paper-log"},留空则自动按上方字段生成' />
</el-form-item>
</el-form>
<el-footer style="height: 40px; text-align: center; margin-top: 10px;">
<el-button plain class="reset-btn" @click="dialogVisible = false">取消</el-button>
<el-button plain class="add-btn" :loading="submitLoading" @click="submit">保存补录</el-button>
</el-footer>
</el-dialog>
</div>
</template>
<script>
import { listJob, listJobDetailsByCode, listManualRecord, saveManualRecord } from '@/api/rack/closedLoop'
const RECORD_TYPE_OPTIONS = ['过站补录', '上挂补录', '下挂补录', '完工补录', '其他补录']
const MISSING_REASON_OPTIONS = ['PLC数据缺失', 'PLC离线', 'PLC通信中断', 'PLC数据丢包', '人工确认补录', '其他']
const emptyForm = () => ({
recordType: '过站补录',
selectedJobCode: '',
jobId: null,
selectedDetailKey: '',
batchId: '',
partId: '',
docNo: '',
qty: 0,
rackCode: '',
stepCode: '',
stationId: '',
missingReason: 'PLC数据缺失',
reasonRemark: '',
operatorName: '',
reviewerName: '',
recordTime: new Date(),
payloadJson: ''
})
export default {
data () {
return {
tableHeight: window.innerHeight - 320,
loading: false,
jobLoading: false,
detailLoading: false,
submitLoading: false,
dialogVisible: false,
dataList: [],
jobOptions: [],
jobDetailOptions: [],
query: {
jobCode: '',
recordType: '',
docNo: ''
},
form: emptyForm(),
recordTypeOptions: RECORD_TYPE_OPTIONS,
missingReasonOptions: MISSING_REASON_OPTIONS,
rules: {
recordType: [{ required: true, message: '请选择补录类型', trigger: 'change' }],
selectedJobCode: [{ required: true, message: '请选择任务单号', trigger: 'change' }],
docNo: [{ required: true, message: '请输入关联单号', trigger: 'blur' }],
qty: [{ type: 'number', required: true, message: '请输入补录数量', trigger: 'change' }],
payloadJson: [{ validator: (rule, value, callback) => this.validatePayloadJson(value, callback), trigger: 'blur' }]
}
}
},
computed: {
summary () {
const result = {
totalCount: this.dataList.length,
totalQty: 0,
typeCount: {}
}
this.dataList.forEach(item => {
const type = item.recordType || '未知'
result.typeCount[type] = (result.typeCount[type] || 0) + 1
result.totalQty += Number(item.qty) || 0
})
return result
}
},
mounted () {
this.loadJobOptions()
this.loadList()
},
methods: {
async loadJobOptions () {
this.jobLoading = true
try {
const { data } = await listJob({})
this.jobOptions = data.rows || []
} finally {
this.jobLoading = false
}
},
resolveJobByCode (jobCode) {
const key = String(jobCode || '').trim()
if (!key) {
return null
}
return (this.jobOptions || []).find(item => item && String(item.jobCode || '').trim() === key) || null
},
async resolveJobIdForQuery () {
const key = String(this.query.jobCode || '').trim()
if (!key) {
return null
}
let matched = this.resolveJobByCode(key)
if (!matched) {
const { data } = await listJob({ jobCode: key })
const rows = data.rows || []
matched = rows.find(item => item && String(item.jobCode || '').trim() === key) || rows[0] || null
}
return matched ? matched.jobId : -1
},
async loadList () {
this.loading = true
try {
const jobId = await this.resolveJobIdForQuery()
if (jobId === -1) {
this.dataList = []
this.$message.warning(`任务单不存在:${this.query.jobCode}`)
return
}
const params = {
recordType: this.query.recordType,
docNo: this.query.docNo
}
if (jobId) {
params.jobId = jobId
}
const { data } = await listManualRecord(params)
this.dataList = (data.rows || []).map(row => ({
...row,
_payload: this.safeParseJson(row.payloadJson)
}))
} finally {
this.loading = false
}
},
resetQuery () {
this.query = { jobCode: '', recordType: '', docNo: '' }
this.loadList()
},
openDialog () {
this.form = emptyForm()
this.jobDetailOptions = []
if (!this.jobOptions.length) {
this.loadJobOptions()
}
if (this.query.jobCode) {
this.form.selectedJobCode = this.query.jobCode
this.onDialogJobChange(this.query.jobCode)
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.manualForm && this.$refs.manualForm.clearValidate()
})
},
async onDialogJobChange (jobCode) {
this.form.jobId = null
this.form.selectedDetailKey = ''
this.form.batchId = ''
this.form.partId = ''
this.jobDetailOptions = []
if (!jobCode) {
return
}
let job = this.resolveJobByCode(jobCode)
if (!job) {
const { data } = await listJob({ jobCode })
const rows = data.rows || []
job = rows.find(item => item && item.jobCode === jobCode) || rows[0] || null
}
this.form.jobId = job ? job.jobId : null
await this.loadJobDetails(jobCode)
},
async loadJobDetails (jobCode) {
if (!jobCode) {
this.jobDetailOptions = []
return
}
this.detailLoading = true
try {
const { data } = await listJobDetailsByCode(jobCode)
const rows = data.rows || []
this.jobDetailOptions = rows.map(item => ({
...item,
_key: `${item.inboundId || ''}_${item.partNo || ''}`
}))
} finally {
this.detailLoading = false
}
},
onDetailChange (key) {
if (!key) {
this.form.batchId = ''
this.form.partId = ''
return
}
const detail = (this.jobDetailOptions || []).find(item => item._key === key)
if (!detail) {
return
}
this.form.batchId = detail.inboundId || ''
this.form.partId = detail.partId || ''
if (!this.form.qty || Number(this.form.qty) <= 0) {
this.form.qty = Number(detail.remainingQty) > 0 ? Number(detail.remainingQty) : (Number(detail.plannedQty) || 0)
}
if (!this.form.docNo) {
this.fillDocNoByContext()
}
},
fillDocNoByContext () {
const code = this.form.selectedJobCode || 'NOJOB'
const step = this.form.stepCode || 'NOSTEP'
const rack = this.form.rackCode || 'NORACK'
this.form.docNo = `${code}-${step}-${rack}-${Date.now()}`
},
safeParseJson (jsonText) {
if (!jsonText) {
return {}
}
try {
return JSON.parse(jsonText)
} catch (e) {
return {}
}
},
payloadValue (row, field) {
const payload = row && row._payload ? row._payload : {}
return payload[field]
},
validatePayloadJson (value, callback) {
if (!value) {
callback()
return
}
try {
JSON.parse(value)
callback()
} catch (e) {
callback(new Error('扩展JSON格式不合法'))
}
},
buildPayloadFromForm () {
let payload = {}
if (this.form.payloadJson) {
payload = JSON.parse(this.form.payloadJson)
}
const selectedDetail = (this.jobDetailOptions || []).find(item => item._key === this.form.selectedDetailKey)
return {
...payload,
fillScene: 'PLC_MISSING',
manualFill: true,
recordType: this.form.recordType,
jobCode: this.form.selectedJobCode || '',
rackCode: this.form.rackCode || '',
stepCode: this.form.stepCode || '',
stationId: this.form.stationId || '',
missingReason: this.form.missingReason || '',
reasonRemark: this.form.reasonRemark || '',
inboundNo: selectedDetail ? (selectedDetail.inboundNo || '') : '',
partNo: selectedDetail ? (selectedDetail.partNo || '') : ''
}
},
async submit () {
if (this.submitLoading) {
return
}
this.$refs.manualForm.validate(async valid => {
if (!valid) {
return
}
let payloadObj = null
try {
payloadObj = this.buildPayloadFromForm()
} catch (e) {
this.$message.warning((e && e.message) || '扩展JSON格式错误')
return
}
this.submitLoading = true
try {
await saveManualRecord({
recordType: this.form.recordType,
docNo: this.form.docNo,
batchId: this.form.batchId || null,
jobId: this.form.jobId || null,
partId: this.form.partId || null,
qty: Number(this.form.qty) || 0,
operatorName: this.form.operatorName,
reviewerName: this.form.reviewerName,
recordTime: this.form.recordTime || new Date(),
payloadJson: JSON.stringify(payloadObj)
})
this.$message.success('补录保存成功')
this.dialogVisible = false
this.loadList()
} catch (e) {
this.$message.error((e && e.message) || '补录保存失败')
} finally {
this.submitLoading = false
}
})
},
recordTypeTag (recordType) {
const map = {
过站补录: 'warning',
上挂补录: 'success',
下挂补录: '',
完工补录: 'danger',
其他补录: 'info'
}
return map[recordType] || 'info'
},
formatDateTime (v) {
if (!v) return '-'
let date = null
if (typeof v === 'number') {
date = new Date(v)
} else if (typeof v === 'string') {
const trimmed = v.trim()
if (/^\d+$/.test(trimmed)) {
date = new Date(Number(trimmed))
} else {
date = new Date(trimmed)
}
} else if (v instanceof Date) {
date = v
} else {
date = new Date(v)
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return String(v).replace('T', ' ')
}
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const mm = String(date.getMinutes()).padStart(2, '0')
const ss = String(date.getSeconds()).padStart(2, '0')
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
}
}
}
</script>
<style scoped>
.overview-card {
margin-bottom: 12px;
}
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.page-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.page-desc {
margin-top: 4px;
color: #909399;
font-size: 12px;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(6, minmax(120px, 1fr));
gap: 10px;
}
.overview-item {
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 10px;
background: #fff;
}
.overview-label {
color: #909399;
font-size: 12px;
}
.overview-value {
margin-top: 6px;
font-size: 20px;
font-weight: 600;
color: #303133;
}
.manual-dialog >>> .el-dialog__body {
padding: 12px 20px 10px;
max-height: 72vh;
overflow-y: auto;
}
.manual-form >>> .el-form-item {
margin-bottom: 10px;
}
.data-table {
background-color: #fff;
border-radius: 4px;
}
.data-table >>> .el-table__header-wrapper th,
.data-table >>> .el-table__fixed-header-wrapper th {
background-color: #f5f7fa !important;
color: #333;
font-weight: 600;
border-color: #ebeef5;
padding: 8px 0;
}
.data-table >>> .el-table__header-wrapper .cell,
.data-table >>> .el-table__fixed-header-wrapper .cell,
.data-table >>> .el-table__body-wrapper .cell,
.data-table >>> .el-table__fixed-body-wrapper .cell {
padding: 0 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px !important;
}
.query-form {
background-color: #fff;
padding: 15px 15px 5px 15px;
border-radius: 4px;
margin-bottom: 12px;
}
.query-form >>> .el-form-item__label {
color: #333;
font-size: 13px;
padding-bottom: 5px;
}
.query-form >>> .el-input__inner {
height: 32px;
line-height: 32px;
border-radius: 4px;
font-size: 13px;
}
.query-form >>> .el-button {
height: 32px;
padding: 0 15px;
font-size: 13px;
border-radius: 4px;
}
.search-btn {
background-color: #ecf5ff;
border-color: #b3d8ff;
color: #409eff;
}
.search-btn:hover {
background-color: #409eff;
border-color: #409eff;
color: #fff;
}
.reset-btn {
background-color: #f5f7fa;
border-color: #d3d4d6;
color: #606266;
}
.reset-btn:hover {
background-color: #909399;
border-color: #909399;
color: #fff;
}
.add-btn {
background-color: #f0f9eb;
border-color: #c2e7b0;
color: #67c23a;
}
.add-btn:hover {
background-color: #67c23a;
border-color: #67c23a;
color: #fff;
}
</style>