Files
bj_power/bj_power_mes/frontend/src/pages/ProcessFlow.vue
T
SunYF d3eda6b588 feat&refactor: 2026-09-22 半成品业务模型重构与功能完善
- 重构半成品流转逻辑:进度权威源改为 workpiece.currentStationNo,新增 completedText 快照字段,废除 doneProcessCodes
- 清理物料品类中半成品类型,存量数据幂等清理,调整物料管理方式文案为批次/序列号
- 新增日排产状态专用更新接口,修复工单工艺校验逻辑
- 新增物料档案代理接口、深路径文件下载接口
- 优化前端界面文案与工位选择逻辑,补充追溯页半成品流转展示
- 调整WMS端物料品类校验与入库接口契约
- 新增/更新数据库表结构与实体类代码
2026-09-22 14:42:52 +08:00

596 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.
<template>
<el-card>
<el-tabs v-model="activeTab">
<!-- ============ 工艺列表 ============ -->
<el-tab-pane label="工艺列表" name="list">
<el-form inline>
<el-form-item>
<el-button type="primary" @click="loadFlows">查询</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="can('produce.processflow:add')" type="success" @click="openFlow()">新增工艺</el-button>
</el-form-item>
</el-form>
<el-table :data="flows" border size="small">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="工艺名称" min-width="130" show-overflow-tooltip />
<el-table-column label="PDF" width="90" align="center">
<template #default="{ row }">
<el-button v-if="row.pdfFile" link type="primary" @click="previewPdf(row.pdfFile)">预览</el-button>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="步骤" min-width="120">
<template #default="{ row }">
<el-tooltip
v-if="row.steps && row.steps.length"
:content="row.steps.map(s => `${s.seq}.${s.name}`).join(' ')">
<span>{{ row.steps.length }} </span>
</el-tooltip>
<span v-else>0 </span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="创建时间" width="150">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="can('produce.processflow:add') || can('produce.processflow:edit')"
size="small" @click="openFlow(row)">编辑</el-button>
<el-button
v-if="can('produce.processflow:edit')"
size="small" :type="row.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row)">
{{ row.status === 'ACTIVE' ? '停用' : '启用' }}
</el-button>
<el-button v-if="can('produce.processflow:delete')" size="small" type="danger" @click="delFlow(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:12px;justify-content:flex-end" background layout="total, prev, pager, next" :total="flowTotal" :page-size="flowPageSize" v-model:current-page="flowPage" @current-change="loadFlows" />
</el-tab-pane>
<!-- ============ 工艺配置新增/编辑共用替代原弹窗套弹窗 ============ -->
<el-tab-pane label="工艺配置" name="config">
<el-alert
v-if="!flowDlg.id && !flowDlg.name"
type="info" :closable="false" show-icon
title="从「工艺列表」点「新增工艺」或某行的「编辑」进入此处;填写后保存即生效。" />
<el-form :model="flowDlg" label-width="90px" style="margin-top:12px">
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="工艺名称" required>
<el-input v-model="flowDlg.name" placeholder="如 3号工位装配工艺" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="状态">
<el-select v-model="flowDlg.status" placeholder="请选择状态" style="width:100%">
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="INACTIVE" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="作业指导PDF">
<el-button
v-if="can('produce.processflow:upload')"
type="primary" plain :loading="flowDlg.uploading" @click="pickPdf">
上传 PDF
</el-button>
<span v-if="flowDlg.pdfFile" class="pdf-name">
{{ flowDlg.pdfFile }}
<el-button link type="primary" @click="previewPdf(flowDlg.pdfFile)">预览</el-button>
</span>
<input ref="pdfInput" type="file" accept="application/pdf" style="display:none" @change="onPdfChange" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注">
<el-input v-model="flowDlg.remark" placeholder="备注(可空)" />
</el-form-item>
<!-- 工艺步骤子表 -->
<el-form-item label="工艺步骤">
<div style="width:100%">
<el-table :data="flowDlg.steps" border size="small">
<el-table-column label="步骤" width="80" align="center">
<template #default="{ row }">
<el-input-number v-model="row.seq" :min="1" size="small" controls-position="right" style="width:70px" />
</template>
</el-table-column>
<el-table-column label="步骤名称" min-width="140">
<template #default="{ row }"><el-input v-model="row.name" placeholder="如 拧紧扭矩" /></template>
</el-table-column>
<el-table-column label="采集方式" width="140">
<template #default="{ row }">
<el-select v-model="row.collectType" placeholder="请选择">
<el-option value="AUTO" label="拧紧枪自动" />
<el-option value="MANUAL" label="手填" />
<el-option value="NONE" label="仅记录" />
</el-select>
</template>
</el-table-column>
<el-table-column label="是否拧紧" width="90" align="center">
<template #default="{ row }"><el-switch v-model="row.isTorque" /></template>
</el-table-column>
<el-table-column label="检测确认" width="90" align="center">
<template #default="{ row }">
<el-switch v-model="row.needCheck" />
<el-tooltip content="开启后,报工前须完成该步骤的检测确认" placement="top"><span style="margin-left:4px;color:#909399;font-size:12px">?</span></el-tooltip>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120">
<template #default="{ row }"><el-input v-model="row.remark" placeholder="备注(可空)" /></template>
</el-table-column>
<el-table-column label="图纸/附件" width="190">
<template #default="{ row }">
<el-upload action="#" :show-file-list="false" multiple
:http-request="(opt) => uploadStepAttachment(opt, row)">
<el-button link type="primary" size="small">上传图纸</el-button>
</el-upload>
<div v-for="(a, ai) in (row.attachment || [])" :key="ai" style="font-size:12px;line-height:1.7">
<el-link type="primary" @click="previewStepFile(a)">{{ a.name }}</el-link>
<el-button link type="danger" size="small" @click="removeStepAttachment(row, ai)">删</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="考核标准" width="110" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openCriteria(row)">
{{ (row.criteria || []).length }} 条
</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="removeStep(row)">删</el-button>
</template>
</el-table-column>
</el-table>
<el-button size="small" style="margin-top:10px" @click="addStep">新增步骤</el-button>
</div>
</el-form-item>
<!-- 物料清单子表(整表覆盖保存;物料编号下拉选择,快照信息取自物料档案) -->
<el-form-item label="物料清单">
<div style="width:100%">
<el-alert v-if="!flowDlg.id" type="info" :closable="false" style="margin-bottom:8px"
title="新工艺请先保存保存后即可维护物料清单" />
<template v-else>
<el-alert v-if="materialsUnavailable" type="warning" :closable="false" style="margin-bottom:8px"
title="物料档案服务不可用清单暂只读不可新增物料行已有行仅可调整单台用量/损耗率" />
<el-table :data="flowDlg.materials" border size="small">
<el-table-column label="物料编号" min-width="150">
<template #default="{ row }">
<el-select v-model="row.materialCode" filterable placeholder="请选择物料编号" style="width:100%"
@change="(code) => onMaterialPicked(row, code)">
<el-option v-for="m in materialOptions" :key="m.code" :label="m.code" :value="m.code" />
</el-select>
</template>
</el-table-column>
<el-table-column label="物料名称" min-width="120">
<template #default="{ row }"><el-input v-model="row.materialName" disabled /></template>
</el-table-column>
<el-table-column label="规格" min-width="110">
<template #default="{ row }"><el-input v-model="row.spec" disabled /></template>
</el-table-column>
<el-table-column label="单位" width="80">
<template #default="{ row }"><el-input v-model="row.unit" disabled /></template>
</el-table-column>
<el-table-column label="管控方式" width="110">
<template #default="{ row }">
<el-select v-model="row.manageMode" disabled>
<el-option value="1" label="批次" />
<el-option value="2" label="序列号" />
</el-select>
</template>
</el-table-column>
<el-table-column label="单台用量" width="120">
<template #default="{ row }">
<el-input-number v-model="row.unitQty" :min="0" size="small" controls-position="right" style="width:100%" />
</template>
</el-table-column>
<el-table-column label="损耗率%" width="110">
<template #default="{ row }">
<el-input-number v-model="row.lossRate" :min="0" size="small" controls-position="right" style="width:100%" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="removeMaterial(row)">删</el-button>
</template>
</el-table-column>
</el-table>
<div style="display:flex;gap:10px;margin-top:10px;align-items:center">
<el-button v-if="!materialsUnavailable" size="small" @click="addMaterial">新增物料行</el-button>
<el-button size="small" type="primary" :loading="matSaving" @click="saveMaterials">保存物料清单</el-button>
<span style="color:#909399;font-size:12px">物料编号取自物料档案编码(结构件通常即图号),选中后名称/规格/单位/管控方式自动带出、不可改;备料需求 = 各工位分产量 × 单台用量 ×(1+损耗率%)。</span>
</div>
</template>
</div>
</el-form-item>
</el-form>
<div style="text-align:right;margin-top:8px">
<el-button @click="activeTab = 'list'">取消</el-button>
<el-button
v-if="can('produce.processflow:add') || can('produce.processflow:edit')"
type="primary" @click="saveFlow">保存</el-button>
</div>
</el-tab-pane>
</el-tabs>
<!-- ============ 考核标准编辑(单层弹窗,从配置 tab 打开,不再嵌套在弹窗内) ============ -->
<el-dialog v-model="critDlg.show" :title="`考核标准 - ${critDlg.stepName || ''}`" width="720px" top="10vh" append-to-body>
<el-alert type="info" :closable="false" style="margin-bottom:12px"
title="步骤设置合格判定标准报工时按此标准自动判定合格/不合格未设标准则该步骤仅记录不判定" />
<el-table :data="critDlg.criteria" border size="small">
<el-table-column label="指标名" min-width="120">
<template #default="{ row }"><el-input v-model="row.name" placeholder=" 扭矩" /></template>
</el-table-column>
<el-table-column label="单位" width="80">
<template #default="{ row }"><el-input v-model="row.unit" placeholder="N·m" /></template>
</el-table-column>
<el-table-column label="逻辑" width="120">
<template #default="{ row }">
<el-select v-model="row.logic" placeholder="请选择">
<el-option value="GE" label=" 大于等于" />
<el-option value="LE" label=" 小于等于" />
<el-option value="GT" label="> 大于" />
<el-option value="LT" label="< 小于" />
<el-option value="RANGE" label="区间" />
<el-option value="EQUAL" label="= 等于" />
</el-select>
</template>
</el-table-column>
<el-table-column v-if="needsTarget" label="目标值" width="110">
<template #default="{ row }">
<el-input-number v-model="row.target" :step="1" controls-position="right" style="width:100%" />
</template>
</el-table-column>
<el-table-column v-if="needsRange" label="下限" width="110">
<template #default="{ row }">
<el-input-number v-model="row.min" :step="1" controls-position="right" style="width:100%" />
</template>
</el-table-column>
<el-table-column v-if="needsRange" label="上限" width="110">
<template #default="{ row }">
<el-input-number v-model="row.max" :step="1" controls-position="right" style="width:100%" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="removeCriterion(row)">删</el-button>
</template>
</el-table-column>
</el-table>
<el-button size="small" style="margin-top:10px" @click="addCriterion">新增标准行</el-button>
<template #footer>
<el-button @click="critDlg.show = false">取消</el-button>
<el-button type="primary" @click="saveCriteria">确定</el-button>
</template>
</el-dialog>
</el-card>
<PageHelp :data="helpProcessFlow" page="ProcessFlow" />
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import PageHelp from '../components/PageHelp.vue'
import { helpProcessFlow } from '../help'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '../utils/request'
const activeTab = ref('list')
const flows = ref([])
const pdfInput = ref(null)
const flowPage = ref(1)
const flowPageSize = ref(10)
const flowTotal = ref(0)
/* ---------- 权限 ---------- */
function can(code) {
try {
const u = JSON.parse(localStorage.getItem('user') || 'null')
if (!u) return false
const owned = u.permissionCodes || []
return u.roleCode === 'SUPER_ADMIN' || owned.includes('*') || owned.includes(code)
} catch {
return false
}
}
function statusText(s) {
return s === 'ACTIVE' ? '启用' : '停用'
}
/* ---------- 工艺 ---------- */
function blankStep(seq) {
return {
_key: Date.now() + Math.random(),
seq,
name: '',
collectType: 'MANUAL',
isTorque: false,
needCheck: false,
remark: '',
attachment: [],
criteria: []
}
}
async function loadFlows() {
const params = { page: flowPage.value, pageSize: flowPageSize.value }
const data = await request.get('/process-flows', { params })
flows.value = (data && data.list) ? data.list : (data || [])
flowTotal.value = (data && data.total) ? data.total : flows.value.length
}
function fmt(v) { return v ? String(v).replace('T', ' ').slice(0, 19) : '-' }
function previewPdf(pdfFile) {
if (pdfFile) window.open('/api/v1/files/' + pdfFile, '_blank')
}
function pickPdf() {
if (pdfInput.value) pdfInput.value.click()
}
async function onPdfChange(e) {
const file = e.target.files && e.target.files[0]
e.target.value = ''
if (!file) return
if (file.type !== 'application/pdf' && !/\.pdf$/i.test(file.name)) {
return ElMessage.warning('仅支持上传 PDF 文件')
}
flowDlg.uploading = true
try {
const fd = new FormData()
fd.append('file', file)
const data = await request.post('/process-flows/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' }
})
flowDlg.pdfFile = (data && data.filename) || ''
ElMessage.success('PDF 上传成功')
} catch {
// 错误提示已由 request 拦截器统一弹出
} finally {
flowDlg.uploading = false
}
}
const flowDlg = reactive({
id: 0, name: '', stations: [], pdfFile: '', status: 'ACTIVE',
remark: '', uploading: false, steps: [blankStep(1)], materials: []
})
const matSaving = ref(false)
function blankMaterial() {
return { materialCode: '', materialName: '', spec: '', unit: '', manageMode: '2', unitQty: 1, lossRate: 0 }
}
/* ---------- 物料档案(物料清单「物料编号」下拉数据源) ---------- */
const materialOptions = ref([])
const materialsUnavailable = ref(false)
async function loadMaterialArchive() {
materialsUnavailable.value = false
try {
const list = (await request.get('/materials')) || []
materialOptions.value = Array.isArray(list) ? list : []
} catch {
materialOptions.value = []
}
if (!materialOptions.value.length) materialsUnavailable.value = true
}
// 选中物料编号后,快照字段(名称/规格/单位/管控方式)自动带出,均不可手改
function onMaterialPicked(row, code) {
const m = materialOptions.value.find((x) => x.code === code)
if (!m) return
row.materialName = m.name || ''
row.spec = m.spec || ''
row.unit = m.unit || ''
if (m.manageMode != null && m.manageMode !== '') row.manageMode = String(m.manageMode)
}
// 加载该工艺的物料清单(整表编辑,保存时整表覆盖)
async function loadMaterials(flowId) {
flowDlg.materials = []
if (!flowId) return
try {
const list = (await request.get(`/process-flows/${flowId}/materials`)) || []
flowDlg.materials = list.map((m) => ({
materialCode: m.materialCode || '', materialName: m.materialName || '', spec: m.spec || '',
unit: m.unit || '', manageMode: m.manageMode || '2', unitQty: m.unitQty ?? 1, lossRate: m.lossRate ?? 0
}))
} catch { /* 拦截器已提示 */ }
}
function addMaterial() {
flowDlg.materials.push(blankMaterial())
}
function removeMaterial(row) {
const i = flowDlg.materials.indexOf(row)
if (i >= 0) flowDlg.materials.splice(i, 1)
}
async function saveMaterials() {
if (!flowDlg.id) return ElMessage.warning('请先保存工艺,再维护物料清单')
const items = flowDlg.materials.map((m) => ({
materialCode: (m.materialCode || '').trim(),
materialName: (m.materialName || '').trim(),
spec: m.spec || '', unit: m.unit || '',
manageMode: m.manageMode || '2',
unitQty: Number(m.unitQty) || 0,
lossRate: Number(m.lossRate) || 0
}))
if (items.some((m) => !m.materialCode)) return ElMessage.warning('存在未选择物料编号的行,请补全或删除')
matSaving.value = true
try {
await request.post(`/process-flows/${flowDlg.id}/materials`, { items })
ElMessage.success('物料清单已保存')
} catch { /* 拦截器已提示 */ } finally {
matSaving.value = false
}
}
function openFlow(row) {
if (row) {
flowDlg.id = row.id
flowDlg.name = row.name || ''
flowDlg.stations = (row.stations && row.stations.length) ? [...row.stations] : []
flowDlg.pdfFile = row.pdfFile || ''
flowDlg.status = row.status || 'ACTIVE'
flowDlg.remark = row.remark || ''
flowDlg.steps = (row.steps || []).map((s, i) => ({
_key: Date.now() + Math.random(),
seq: s.seq || i + 1,
name: s.name || '',
collectType: s.collectType || 'MANUAL',
isTorque: !!s.isTorque,
needCheck: !!s.needCheck,
remark: s.remark || '',
attachment: (s.attachment || []).map((a) => ({ ...a })),
criteria: (s.criteria || []).map((c) => ({ ...c }))
}))
loadMaterials(row.id)
} else {
Object.assign(flowDlg, {
id: 0, name: '', stations: [], pdfFile: '', status: 'ACTIVE', remark: '', steps: [], materials: []
})
}
if (!flowDlg.steps.length) flowDlg.steps.push(blankStep(1))
activeTab.value = 'config'
}
function addStep() {
const maxSeq = flowDlg.steps.reduce((m, s) => Math.max(m, s.seq || 0), 0)
flowDlg.steps.push(blankStep(maxSeq + 1))
}
function removeStep(row) {
const i = flowDlg.steps.indexOf(row)
if (i >= 0) flowDlg.steps.splice(i, 1)
}
/* ---------- 步骤图纸/附件(Item J:图纸绑定工艺步骤,按工序分发到工位终端)---------- */
async function uploadStepAttachment(option, row) {
const fd = new FormData()
fd.append('file', option.file)
try {
const data = await request.post('/process-flows/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' }
})
if (!row.attachment) row.attachment = []
row.attachment.push({ name: option.file.name, url: (data && data.url) || '' })
if (option.onSuccess) option.onSuccess()
ElMessage.success('图纸已上传')
} catch (e) {
if (option.onError) option.onError(e)
}
}
function removeStepAttachment(row, i) {
if (row.attachment) row.attachment.splice(i, 1)
}
function previewStepFile(a) {
if (a && a.url) window.open(a.url, '_blank')
}
/* ---------- 考核标准(单层弹窗) ---------- */
const critDlg = reactive({ show: false, stepName: '', criteria: [], _step: null })
const needsTarget = computed(() => critDlg.criteria.some((c) => c.logic && c.logic !== 'RANGE'))
const needsRange = computed(() => critDlg.criteria.some((c) => c.logic === 'RANGE'))
function openCriteria(step) {
critDlg._step = step
critDlg.stepName = step.name || '(未命名步骤)'
critDlg.criteria = (step.criteria || []).map((c) => ({ ...c }))
critDlg.show = true
}
function addCriterion() {
critDlg.criteria.push({ name: '', unit: '', logic: 'GE', target: null, min: null, max: null })
}
function removeCriterion(c) {
const i = critDlg.criteria.indexOf(c)
if (i >= 0) critDlg.criteria.splice(i, 1)
}
function saveCriteria() {
if (critDlg._step) {
critDlg._step.criteria = critDlg.criteria
.filter((c) => c.name)
.map((c) => ({ ...c }))
}
critDlg.show = false
}
async function saveFlow() {
if (!flowDlg.name) return ElMessage.warning('请填写工艺名称')
// 绑定关系由「关联工位」页维护:此处只保存流程本身(不收集/不改动工位绑定)
const body = {
id: flowDlg.id || undefined,
name: flowDlg.name,
stations: flowDlg.stations,
pdfFile: flowDlg.pdfFile,
status: flowDlg.status,
remark: flowDlg.remark,
steps: flowDlg.steps.map(({ _key, ...st }) => ({
seq: st.seq,
name: st.name,
collectType: st.collectType,
isTorque: st.isTorque,
needCheck: st.needCheck,
remark: st.remark,
attachment: st.attachment || [],
criteria: st.criteria
}))
}
await request.post('/process-flows', body)
ElMessage.success('保存成功')
activeTab.value = 'list'
loadFlows()
}
async function delFlow(row) {
await ElMessageBox.confirm('确认删除该工艺?删除将同时解除其绑定的工位。', '提示', { type: 'warning' })
await request.delete(`/process-flows/${row.id}`)
ElMessage.success('删除成功')
loadFlows()
}
async function toggleStatus(row) {
const toStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
const action = toStatus === 'INACTIVE' ? '停用' : '启用'
if (toStatus === 'INACTIVE') {
await ElMessageBox.confirm(
`确认停用「${row.name}」?停用后绑定该工艺的工位将不再加载其作业步骤(绑定关系保留,启用后自动恢复)。`,
'提示', { type: 'warning' })
} else {
await ElMessageBox.confirm(
`确认启用「${row.name}」?启用后其绑定工位(${(row.stations || []).map(s => '工位' + s).join('、') || '-'})将恢复使用该工艺。`,
'提示', { type: 'warning' }
)
}
await request.post('/process-flows/status', { id: row.id, status: toStatus })
ElMessage.success(`已${action}`)
loadFlows()
}
onMounted(() => {
loadFlows()
loadMaterialArchive()
})
</script>
<style scoped>
.pdf-name { margin-left: 10px; color: #606266; font-size: 13px; }
</style>