refactor: 重构产线工位与备料流程,移除冗余字段

1. 移除工单、工件、日排产中的冗余工位组合/接驳台字段
2. 新增工位接驳台编码字段与唯一约束,关联WMS接驳台主数据
3. 重构日排产为工位产量分配模式,替代原接驳台列表
4. 新增备料单工位级字段与分批出库支持
5. 移除定时同步可生产数量,改为WMS实时拉取接口
6. 过滤操作日志,排除登录/退出相关日志
7. 调整仪表盘工序展示逻辑为今日派工路线
8. 新增接驳台维护菜单与基础数据
This commit is contained in:
SunYF
2026-09-19 07:50:21 +08:00
parent 543655d441
commit 37f10d3a13
156 changed files with 4489 additions and 1285 deletions
+87 -40
View File
@@ -20,10 +20,10 @@
<el-table-column prop="planDate" label="排产日期" width="120" />
<el-table-column prop="planQty" label="计划数量" width="100" />
<el-table-column prop="completedQty" label="已完成" width="90" />
<el-table-column label="送达接驳台" min-width="150">
<el-table-column label="送达接驳台(派生)" min-width="160">
<template #default="{ row }">
<el-tag v-for="d in (row.dockCodes||[])" :key="d" size="small" class="dock-tag">{{ d }}</el-tag>
<span v-if="!(row.dockCodes||[]).length" class="dock-empty">指定</span>
<el-tag v-for="d in (dockByDate[row.planDate] || [])" :key="d" size="small" class="dock-tag">{{ d }}</el-tag>
<span v-if="!(dockByDate[row.planDate] || []).length" class="dock-empty">派工</span>
</template>
</el-table-column>
<el-table-column label="执行状态" width="110">
@@ -45,7 +45,7 @@
</el-table-column>
</el-table>
<el-dialog v-model="dialog.show" :title="dialog.id ? '编辑排产' : '新建排产'" width="560px">
<el-dialog v-model="dialog.show" :title="dialog.id ? '编辑排产' : '新建排产'" width="600px">
<el-form :model="dialog" label-width="92px">
<el-form-item label="工单号">
<el-select v-model="dialog.orderNo" filterable allow-create placeholder="请选择/输入工单号" style="width:100%" @change="onOrderChange">
@@ -75,28 +75,29 @@
</el-form-item>
<el-alert type="info" :closable="false" style="margin-bottom:12px"
:title="`共 ${previewDates.length} 天:${previewDates.join('、') || '-'}`" />
<el-alert type="info" :closable="false" style="margin-bottom:12px"
title="送达接驳台与工位产量分配由「工位派工」按排产日自动派生,此处无需手填;如需调整工位产量,启用后点击「编辑」设置。" />
</template>
<!-- 编辑单日 -->
<template v-else>
<el-form-item label="排产日期">
<el-date-picker v-model="dialog.planDate" type="date" value-format="YYYY-MM-DD" style="width:100%" />
<el-date-picker v-model="dialog.planDate" type="date" value-format="YYYY-MM-DD" style="width:100%" @change="loadAlloc" />
</el-form-item>
<el-form-item label="计划数量">
<el-input-number v-model="dialog.planQty" :min="1" style="width:100%" />
</el-form-item>
<el-form-item label="工位产量分配">
<div v-if="dispatchStations.length" style="width:100%">
<div v-for="st in dispatchStations" :key="st" style="display:flex;gap:8px;align-items:center;margin-bottom:6px">
<span style="width:150px">工位{{ st }}<template v-if="stationNameMap[st]">{{ stationNameMap[st] }}</template></span>
<el-input-number v-model="alloc[st]" :min="0" :max="dialog.planQty" controls-position="right" style="width:150px" />
<span style="color:#909399;font-size:12px"></span>
</div>
<div style="font-size:12px;color:#909399">已分配合计{{ allocTotal }} / 计划 {{ dialog.planQty }}建议相等留空或 0 的工位不分担产量</div>
</div>
<el-alert v-else type="info" :closable="false" title="该排产日暂无工位派工,无法分配工位产量;请先在「工位」页完成当日工位↔工序派工。" />
</el-form-item>
</template>
<el-form-item label="送达接驳台" prop="dockCodes">
<el-select v-model="dialog.dockCodes" multiple filterable clearable placeholder="该工单物料送达哪些接驳台(可多选)" style="width:100%">
<el-option-group label="产线接驳台(DOCK n↔工位n)">
<el-option v-for="n in 20" :key="n" :label="`DOCK${String(n).padStart(2,'0')} · 工位${n}`" :value="`DOCK${String(n).padStart(2,'0')}`" />
</el-option-group>
<el-option-group label="库房">
<el-option label="DOCK21 · 库房收货" value="DOCK21" />
</el-option-group>
</el-select>
<div class="dock-tip">选需送达的接驳台建议为当前工单上次使用的接驳台</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog.show=false">取消</el-button>
@@ -114,7 +115,6 @@
<style scoped>
.dock-tag { margin-right: 4px; }
.dock-empty { color: #999; font-size: 12px; }
.dock-tip { width: 100%; font-size: 12px; color: #888780; line-height: 1.5; }
</style>
<script setup>
@@ -130,7 +130,17 @@ const query = reactive({ orderNo: '', planDate: '' })
const list = ref([])
const workOrders = ref([])
const productTypes = ref([])
const dialog = reactive({ show: false, id: 0, orderNo: '', planDate: '', planRange: [], mode: 'AVG', planQty: 1, dockCodes: [], productType: '', productCode: '', productName: '', woQty: '' })
const dialog = reactive({ show: false, id: 0, orderNo: '', planDate: '', planRange: [], mode: 'AVG', planQty: 1, productType: '', productCode: '', productName: '', woQty: '', stationPlanQty: {} })
// 工位派工派生:工位号→接驳台编码、工位号→名称
const stationDockMap = ref({})
const stationNameMap = ref({})
// 排产日→送达接驳台列表(派生展示)
const dockByDate = ref({})
// 编辑时工位产量分配
const dispatchStations = ref([])
const alloc = reactive({})
const allocTotal = computed(() => Object.values(alloc).reduce((a, b) => a + (Number(b) || 0), 0))
// 新建排产「日期范围 + 模式」预览:展开为去重升序日期列表(仅前端提示,后端以 planRange 为准)
const previewDates = computed(() => {
@@ -147,7 +157,6 @@ const previewDates = computed(() => {
return out
})
const dockOptions = [...Array.from({ length: 20 }, (_, i) => `DOCK${String(i + 1).padStart(2, '0')}`), 'DOCK21']
const statusOptions = [
{ v: 'PENDING', l: '未启用' }, { v: 'CONFIRMED', l: '已启用' },
{ v: 'PROCESSING', l: '执行中' }, { v: 'DONE', l: '已完成' }, { v: 'CANCELLED', l: '已取消' }
@@ -156,16 +165,53 @@ const statusText = (s) => (statusOptions.find((x) => x.v === s) || {}).l || s
const planStatusTag = (s) =>
s === 'DONE' ? 'success' : s === 'PROCESSING' ? 'warning' : s === 'CONFIRMED' ? 'primary' : 'info'
async function loadStations() {
const sts = (await request.get('/stations')) || []
const dm = {}
const nm = {}
sts.forEach((s) => { dm[s.stationNo] = s.dockCode || ''; nm[s.stationNo] = s.name })
stationDockMap.value = dm
stationNameMap.value = nm
}
async function load() {
list.value = (await request.get('/daily-plans', { params: query })) || []
await refreshDocks()
}
// 推荐:该工单上次使用的接驳台
async function loadSuggest() {
if (!dialog.orderNo) return
try {
const d = await request.get('/daily-plans/suggest-docks', { params: { orderNo: dialog.orderNo } })
dialog.dockCodes = d?.dockCodes || []
} catch { dialog.dockCodes = [] }
// 根据各排产日的工位派工路线,派生送达接驳台(展示用,来源于 station_process + station.dockCode
async function refreshDocks() {
const dates = [...new Set(list.value.map((r) => r.planDate))]
const out = {}
for (const d of dates) {
const r = await request.get('/station-process/route', { params: { effectDate: d, shift: '' } })
const stations = (r && r.stations) || []
const seen = {}
const ds = []
stations.forEach((n) => {
const dc = stationDockMap.value[n]
if (dc && !seen[dc]) { seen[dc] = true; ds.push(dc) }
})
out[d] = ds
}
dockByDate.value = out
}
// 加载某排产日的工位派工,初始化工位产量分配
async function loadAlloc(date) {
const r = await request.get('/station-process/route', { params: { effectDate: date, shift: '' } })
const stations = (r && r.stations) || []
dispatchStations.value = stations
const init = {}
stations.forEach((st) => { init[st] = 0 })
const spq = dialog.stationPlanQty || {}
const hasAny = stations.some((st) => spq[String(st)] != null)
if (hasAny) {
stations.forEach((st) => { if (spq[String(st)] != null) init[st] = spq[String(st)] })
} else if (stations.length) {
const base = Math.floor(dialog.planQty / stations.length)
stations.forEach((st, i) => { init[st] = base + (i < dialog.planQty % stations.length ? 1 : 0) })
}
Object.keys(alloc).forEach((k) => { delete alloc[k] })
Object.assign(alloc, init)
}
// 选工单号自动带出其产品信息(只读展示,避免手填错位)
function loadWorkOrderInfo() {
@@ -181,32 +227,35 @@ function loadWorkOrderInfo() {
dialog.productType = pt ? (pt.name + (pt.code ? ' / ' + pt.code : '')) : ''
}
function onOrderChange() {
loadSuggest()
loadWorkOrderInfo()
}
function openAdd() {
const today = new Date().toISOString().slice(0, 10)
Object.assign(dialog, {
show: true, id: 0, orderNo: '', planDate: '', planRange: [today, today], mode: 'AVG',
planQty: 1, dockCodes: [], productType: '', productCode: '', productName: '', woQty: ''
planQty: 1, productType: '', productCode: '', productName: '', woQty: '', stationPlanQty: {}
})
}
function openEdit(row) {
const spq = row.stationPlanQty || {}
Object.assign(dialog, {
show: true, id: row.id, orderNo: row.orderNo, planDate: row.planDate, planRange: [],
mode: 'AVG', planQty: row.planQty, dockCodes: row.dockCodes || [],
productType: '', productCode: '', productName: '', woQty: ''
mode: 'AVG', planQty: row.planQty, productType: '', productCode: '', productName: '', woQty: '', stationPlanQty: spq
})
loadWorkOrderInfo()
loadAlloc(row.planDate)
}
async function save() {
if (!dialog.orderNo) return ElMessage.warning('请选择工单号')
if (!dialog.dockCodes.length) return ElMessage.warning('请确认送达接驳台(AGV 配送目标)')
if (dialog.id) {
// 编辑单条日排产
// 编辑单条日排产:工位产量分配按派工工位拆分
const spq = {}
dispatchStations.value.forEach((st) => {
if (alloc[st]) spq[String(st)] = Number(alloc[st]) || 0
})
if (!dialog.planDate) return ElMessage.warning('请选择排产日期')
await request.put(`/daily-plans/${dialog.id}`, {
id: dialog.id, orderNo: dialog.orderNo, planDate: dialog.planDate, planQty: dialog.planQty, dockCodes: dialog.dockCodes
id: dialog.id, orderNo: dialog.orderNo, planDate: dialog.planDate, planQty: dialog.planQty, stationPlanQty: spq
})
} else {
// 新建批量:按日期范围 + 模式生成多天日排产
@@ -214,8 +263,7 @@ async function save() {
return ElMessage.warning('请选择排产日期范围')
if (!dialog.planQty || dialog.planQty < 1) return ElMessage.warning('请填写数量')
await request.post('/daily-plans', {
orderNo: dialog.orderNo, planRange: dialog.planRange, mode: dialog.mode,
planQty: dialog.planQty, dockCodes: dialog.dockCodes
orderNo: dialog.orderNo, planRange: dialog.planRange, mode: dialog.mode, planQty: dialog.planQty
})
}
ElMessage.success('保存成功')
@@ -231,7 +279,6 @@ function openDetail(row) {
{ key: 'planDate', label: '排产日期' },
{ key: 'planQty', label: '计划数量' },
{ key: 'completedQty', label: '已完成' },
{ key: 'dockCodes', label: '送达接驳台', format: (v) => (v && v.length ? v.join('、') : '未指定') },
{ key: 'status', label: '执行状态', type: 'enum', map: statusOptions.reduce((m, x) => { m[x.v] = x.l; return m }, {}) },
{ key: 'operator', label: '操作人' }
]
@@ -249,8 +296,7 @@ async function toggleStatus(row) {
const label = target === 'CONFIRMED' ? '启用' : '停用'
await ElMessageBox.confirm(`确认${label}该排产(${row.orderNo} ${row.planDate})?`, '提示', { type: 'warning' })
await request.put(`/daily-plans/${row.id}`, {
orderNo: row.orderNo, planDate: row.planDate, planQty: row.planQty,
dockCodes: row.dockCodes || [], status: target
orderNo: row.orderNo, planDate: row.planDate, planQty: row.planQty, status: target
})
ElMessage.success(`${label}`)
load()
@@ -258,6 +304,7 @@ async function toggleStatus(row) {
onMounted(async () => {
workOrders.value = (await request.get('/work-orders')) || []
productTypes.value = (await request.get('/product-types')) || []
await loadStations()
load()
})
</script>
</script>
+3 -3
View File
@@ -65,8 +65,8 @@ import PageHelp from '../components/PageHelp.vue'
import { helpEventLog } from '../help'
// 事件类型族(后端写点 eventType 前缀,如 work.order.create / scan.report …)
// 登录/退出(auth.*)不属于操作日志,后端已排除、本页也不提供该筛选项
const EVENT_FAMILIES = [
{ label: '登录/退出', value: 'auth.' },
{ label: '工单', value: 'work.order.' },
{ label: '日排产', value: 'daily.plan.' },
{ label: '物料清单', value: 'bom.' },
@@ -82,13 +82,13 @@ const EVENT_FAMILIES = [
]
const FAMILY_LABEL = Object.fromEntries(EVENT_FAMILIES.map((f) => [f.value, f.label]))
const FAMILY_TAG = {
'auth.': 'danger', 'work.order.': 'primary', 'daily.plan.': 'success', 'bom.': 'warning',
'work.order.': 'primary', 'daily.plan.': 'success', 'bom.': 'warning',
'material.request.': 'warning', 'process.flow.': 'info', 'station.': 'info',
'plc.': 'danger', 'torque.': 'info', 'scan.': 'success', 'workpiece.': 'success',
'inspection.': 'warning', 'semi.': 'info'
}
const ENTITY_LABEL = {
auth: '登录', work_order: '工单', product_bom: '物料清单', material_request: '备料单',
work_order: '工单', product_bom: '物料清单', material_request: '备料单',
process_flow: '工艺流程', station: '工位', workpiece: '工件', torque: '拧紧',
inspection: '检验', scan: '报工', daily_plan: '日排产', plc: '下发', semi: '半成品'
}
@@ -11,10 +11,13 @@
<el-form-item label="图号"><el-input v-model="query.materialCode" clearable style="width:150px" placeholder="不区分大小写" @keyup.enter="load" /></el-form-item>
<el-form-item label="物料名称"><el-input v-model="query.materialName" clearable style="width:150px" @keyup.enter="load" /></el-form-item>
<el-form-item label="接驳台"><el-input v-model="query.targetDock" clearable style="width:120px" placeholder="如 DOCK05" @keyup.enter="load" /></el-form-item>
<el-form-item label="工位"><el-input v-model="query.stationNo" clearable style="width:90px" placeholder="如 3" @keyup.enter="load" /></el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" clearable placeholder="全部" style="width:130px">
<el-option label="待备料" value="PENDING" />
<el-option label="已确认" value="CONFIRMED" />
<el-option label="已锁定" value="LOCKED" />
<el-option label="配送中" value="DELIVERING" />
<el-option label="已接料完结" value="DONE" />
</el-select>
</el-form-item>
<el-form-item>
@@ -28,15 +31,32 @@
<el-table-column prop="orderNo" label="工单号" min-width="130" />
<el-table-column prop="planDate" label="排产日期" width="110" />
<el-table-column prop="materialCode" label="图号" min-width="110" />
<el-table-column prop="materialName" label="物料名称" min-width="110" />
<el-table-column prop="materialName" label="物料名称" min-width="110" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="70" />
<el-table-column prop="manageMode" label="管理方式" width="110">
<template #default="{row}">{{ row.manageMode === '1' ? '结构件(批次)' : '电气件(SN)' }}</template>
</el-table-column>
<el-table-column prop="reqQty" label="需求数量" width="90" />
<el-table-column prop="status" label="状态" width="90">
<el-table-column prop="stationNo" label="工位" width="70" align="center" />
<el-table-column prop="processCode" label="工序" width="70" align="center" />
<el-table-column prop="targetDock" label="接驳台" width="100" align="center" />
<el-table-column prop="batchNo" label="批次" width="120" align="center" show-overflow-tooltip />
<el-table-column prop="reqQty" label="需求数量" width="90" align="center" />
<el-table-column label="已接料" width="90" align="center">
<template #default="{row}">{{ fmtQty(row.sentQty) }}</template>
</el-table-column>
<el-table-column label="分批进度" min-width="150">
<template #default="{row}">
<el-progress :percentage="progressPct(row)" :status="row.status === 'DONE' ? 'success' : ''" />
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{row}">{{ statusText(row.status) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{row}">
<el-button size="small" type="success" :disabled="row.status === 'DONE'" :loading="receiving && receivingNo === row.requestNo" @click="receive(row)">已接料</el-button>
</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="90" />
</el-table>
</el-card>
@@ -51,14 +71,48 @@ import { can } from '../utils/perm'
import PageHelp from '../components/PageHelp.vue'
import { helpMaterial } from '../help'
const query = reactive({ planDate: '', orderNo: '', status: '', materialCode: '', materialName: '', targetDock: '' })
const query = reactive({ planDate: '', orderNo: '', status: '', materialCode: '', materialName: '', targetDock: '', stationNo: '' })
const list = ref([])
const receiving = ref(false)
const receivingNo = ref('')
const statusText = (s) => (s === 'PENDING' ? '待备料' : s === 'CONFIRMED' ? '已确认' : s)
const statusText = (s) => (s === 'PENDING' ? '待备料' : s === 'LOCKED' ? '已锁定' : s === 'DELIVERING' ? '配送中' : s === 'DONE' ? '已接料完结' : s)
function fmtQty(v) {
const n = Number(v || 0)
return Number.isInteger(n) ? String(n) : n.toFixed(2)
}
function progressPct(row) {
const req = Number(row.reqQty || 0)
const sent = Number(row.sentQty || 0)
if (req <= 0) return 0
return Math.min(100, Math.round((sent / req) * 100))
}
async function load() {
list.value = (await request.get('/material-requests', { params: query })) || []
}
// 已接料(班组长补录入口):确认本批全部剩余(sentQty 补到 reqQty),达到即置 DONE
async function receive(row) {
if (!row?.requestNo) return
try {
await ElMessageBox.confirm(`确认「${row.requestNo}」已接料(物料将累计到已发料,达需求即完结)?`, '接料确认', { type: 'warning' })
} catch {
return
}
receiving.value = true
receivingNo.value = row.requestNo
try {
await request.post('/material-requests/receive', { requestNo: row.requestNo })
ElMessage.success('已接料确认:' + row.requestNo)
load()
} catch {
// 拦截器已提示
}
receiving.value = false
receivingNo.value = ''
}
async function generate() {
if (!query.planDate) return ElMessage.warning('请先选择排产日期')
await ElMessageBox.confirm(
+9 -25
View File
@@ -1,7 +1,7 @@
<template>
<el-card>
<el-alert type="info" :closable="false" style="margin-bottom:12px"
title="工位组合下发:把某工单/工件要经过的「工位组合」写入产线设备(如 PLC 控制器),产线传送带据此只停靠对应工位。系统保证「上一条未收到完成信号前不下发下一条」,避免串序。" />
title="工位组合下发:把某工单/工件要经过的「工位组合」写入产线设备(如 PLC 控制器),产线传送带据此只停靠对应工位。工位组合由今日「工位派工(station_process)」自动派生,无需手动选择;如需调整路线,请到「工位」页改派工。系统保证「上一条未收到完成信号前不下发下一条」,避免串序。" />
<el-tabs v-model="tab" @tab-change="onTab">
<!-- Tab1 下发录入默认打开 -->
@@ -24,9 +24,6 @@
<el-tag v-else type="info" size="large">未带出选择 SN 后自动识别</el-tag>
</el-form-item>
</el-form>
<el-form-item label="工位组合">
<ProcessSeqPicker v-model="send.processCombination" :disabled-codes="disabledStations" />
</el-form-item>
<el-form-item>
<el-button v-if="can('produce.plc:send')" type="primary" @click="doSend">下发工位码</el-button>
</el-form-item>
@@ -69,19 +66,18 @@
</template>
<script setup>
import { reactive, ref, computed, onMounted } from 'vue'
import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '../utils/request'
import { can } from '../utils/perm'
import PageHelp from '../components/PageHelp.vue'
import ProcessSeqPicker from '../components/ProcessSeqPicker.vue'
import { helpPlc } from '../help'
// 多 Tab 范式:下发录入在前默认打开,下发记录在后点击才加载。
// 当前工位语义(2026-09-08):选择工件 SN 后由工件实际进度自动带出(只读);
// ≤当前工位的工位视为已完成,自动从组合中剔除并禁选。
// 工位组合由今日「工位派工(station_process)」自动派生,不再手选。
const tab = ref('send')
const send = reactive({ orderNo: '', sn: '', stationNo: 0, processCombination: '' })
const send = reactive({ orderNo: '', sn: '', stationNo: 0 })
const query = reactive({ orderNo: '', status: '' })
const logs = ref([])
const workOrders = ref([])
@@ -90,24 +86,12 @@ const currentStation = ref(0)
const plcStatusText = (s) => (s === 'DONE' ? '已完成' : '已下发')
// 已完成(≤当前工位)禁选
const disabledStations = computed(() => {
if (!currentStation.value) return []
return Array.from({ length: currentStation.value }, (_, i) => i + 1)
})
// SN 变化:从工件进度带出当前工位;同时把组合里 ≤当前工位的编号自动剔除
// SN 变化:从工件进度带出当前工位
function onSnChange() {
currentStation.value = 0
if (!send.sn) return
const wp = orderSns.value.find((w) => w.sn === send.sn)
const cp = Number(wp?.currentProcess || wp?.currentStation || 0)
currentStation.value = cp
if (cp > 0 && send.processCombination) {
const kept = String(send.processCombination).split(',')
.map(Number).filter((n) => n > cp).sort((a, b) => a - b)
send.processCombination = kept.join(',')
}
currentStation.value = Number(wp?.currentProcess || wp?.currentStation || 0)
}
// 选中工单后带出该工单已上线/未完工的 SN 供选择;SN 也可扫码手输
@@ -121,14 +105,14 @@ async function onOrderChange() {
}
async function doSend() {
if (!send.orderNo || !send.processCombination) return ElMessage.warning('请选择工单号和工位组合')
if (!send.orderNo) return ElMessage.warning('请选择工单号')
if (!send.sn) return ElMessage.warning('请选择/扫码输入该工单下的工件 SN')
if (!currentStation.value) return ElMessage.warning('未识别到工件当前工位,请先选择已上线工件的 SN')
send.stationNo = currentStation.value
try {
await request.post('/plc/send-process', { ...send })
// 工位组合由后端按今日工位派工(station_process)自动派生,前端只传工单/SN/当前工位
await request.post('/plc/send-process', { orderNo: send.orderNo, sn: send.sn, stationNo: send.stationNo })
ElMessage.success('工位码下发成功(未收到完成信号不会下发下一条)')
send.processCombination = ''
} catch (e) { /* 拦截器已提示,如"上一条未完成" */ }
}
async function loadLogs() {
+140 -7
View File
@@ -5,6 +5,7 @@
<div style="margin-bottom:12px">
<el-button v-if="can('produce.station:add')" type="primary" @click="openAdd">新增工位</el-button>
<el-button v-if="can('produce.station:dispatch')" type="warning" plain @click="openDispatch">工位派工</el-button>
<span style="margin-left:12px;color:#909399;font-size:12px">虚拟工位 0 上线位13 下线位类型选线下位不连 PLC仅记录上下线</span>
</div>
@@ -25,11 +26,19 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="接驳台" width="90" align="center">
<el-table-column label="接驳台(实体)" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.hasDock ? 'success' : 'info'">{{ row.hasDock ? '有' : '无' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="接驳台编码" min-width="150">
<template #default="{ row }">
<el-select :model-value="row.dockCode" size="small" clearable placeholder="未绑定" style="width:130px"
:disabled="!row.hasDock" @change="(v) => bindDock(row, v || '')">
<el-option v-for="d in dockOptions" :key="d" :label="d" :value="d" />
</el-select>
</template>
</el-table-column>
<el-table-column label="绑定工艺流程" min-width="240">
<template #default="{ row }">
<el-select :model-value="row.flowId" size="small" placeholder="未绑定" @change="(v) => bindFlow(row, v)" style="width:100%">
@@ -66,12 +75,55 @@
内置工位需与 PLC 通讯并按顺序流转工位类型不可修改
</div>
</el-form-item>
<el-form-item v-if="dlg.isBuiltin && dlg.hasDock" label="接驳台编码">
<el-select v-model="dlg.dockCode" clearable placeholder="未绑定接驳台" style="width:100%">
<el-option v-for="d in dockOptions" :key="d" :label="d" :value="d" />
</el-select>
<div style="color:#909399;font-size:12px;line-height:1.5;margin-top:4px">
绑定到 WMS 接驳台主数据与工位 1:1一个接驳台只能给一个工位无接驳台留空
</div>
</el-form-item>
<el-form-item v-else label="接驳台编码">
<div style="color:#909399;font-size:12px;line-height:1.5">
该工位无实体接驳台是否有接驳台由内置数据给定页面不可更改无需绑定
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dlg.show = false">取消</el-button>
<el-button v-if="can('produce.station:edit')" type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
<!-- 工位派工工位工序替代原工位组合串 -->
<el-dialog v-model="dispatch.show" title="工位派工(工位↔工序)" width="680px">
<el-alert type="info" :closable="false" style="margin-bottom:12px"
title="按生效日+班次定义每个工位执行的工序编号(1~12)。同工序的工位可连续排布(如 3/5/7 同为工序1);已派工工位须沿工位号单调不降、不可穿插(不能在工序1 的 3/5 之间插入工序2 的 4)。工序=0 表示该工位当日不参与生产。" />
<el-form inline>
<el-form-item label="生效日期">
<el-date-picker v-model="dispatch.effectDate" type="date" value-format="YYYY-MM-DD" style="width:170px" @change="loadDispatch" />
</el-form-item>
<el-form-item label="班次">
<el-input v-model="dispatch.shift" placeholder="默认空=单班" style="width:140px" @input="loadDispatch" />
</el-form-item>
</el-form>
<el-table :data="dispatch.rows" border size="small" max-height="360">
<el-table-column prop="stationNo" label="工位号" width="80" align="center" />
<el-table-column prop="name" label="工位名称" min-width="160" show-overflow-tooltip />
<el-table-column label="工序编号" width="200">
<template #default="{ row }">
<el-select v-model="row.processCode" clearable placeholder="0=不参与" style="width:170px">
<el-option :value="0" label="0 不参与" />
<el-option v-for="p in processOptions" :key="p" :value="p" :label="`${p} 工序${p}`" />
</el-select>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="dispatch.show=false">取消</el-button>
<el-button type="primary" @click="saveDispatch">保存派工</el-button>
</template>
</el-dialog>
</el-card>
<PageHelp :data="helpStation" page="Station" />
</template>
@@ -87,13 +139,28 @@ import { helpStation } from '../help'
const stations = ref([])
const flows = ref([])
// 接驳台编码选项(真源在 WMS dock 表,从 /docks 拉取;拉取失败降级为写死兜底)
const dockOptions = ref([...Array.from({ length: 20 }, (_, i) => `DOCK${String(i + 1).padStart(2, '0')}`)])
async function loadDocks() {
try {
const list = (await request.get('/docks')) || []
const codes = (Array.isArray(list) ? list : []).map((d) => d.dockCode).filter(Boolean)
if (codes.length) dockOptions.value = codes
} catch {
// 降级保留写死兜底
}
}
// 工序编号(产品级 1~12
const processOptions = [...Array.from({ length: 12 }, (_, i) => i + 1)]
const rows = computed(() => {
return [...stations.value]
.sort((a, b) => (a.stationNo || 0) - (b.stationNo || 0))
.map((s) => ({ id: s.id, stationNo: s.stationNo, name: s.name, flowId: s.flowId, flowName: s.flowName, stationType: s.stationType, isBuiltin: !!s.isBuiltin, hasDock: !!s.hasDock }))
.map((s) => ({ id: s.id, stationNo: s.stationNo, name: s.name, flowId: s.flowId, flowName: s.flowName, stationType: s.stationType, isBuiltin: !!s.isBuiltin, hasDock: !!s.hasDock, dockCode: s.dockCode || '' }))
})
const dlg = reactive({ show: false, isAdd: false, title: '', id: 0, stationNo: 1, name: '', stationType: 'LINE', isBuiltin: false })
const dlg = reactive({ show: false, isAdd: false, title: '', id: 0, stationNo: 1, name: '', stationType: 'LINE', isBuiltin: false, hasDock: false, dockCode: '' })
async function load() {
stations.value = (await request.get('/stations')) || []
@@ -103,11 +170,11 @@ function openAdd() {
const used = new Set(stations.value.map((s) => s.stationNo))
let next = 14
while (used.has(next)) next++
Object.assign(dlg, { show: true, isAdd: true, title: '新增工位', id: 0, stationNo: next, name: '', stationType: 'LINE', isBuiltin: false })
Object.assign(dlg, { show: true, isAdd: true, title: '新增工位', id: 0, stationNo: next, name: '', stationType: 'LINE', isBuiltin: false, hasDock: false, dockCode: '' })
}
function openName(row) {
Object.assign(dlg, { show: true, isAdd: false, title: `工位${row.stationNo} - 修改`, id: row.id, stationNo: row.stationNo, name: row.name, stationType: row.stationType || 'LINE', isBuiltin: !!row.isBuiltin })
Object.assign(dlg, { show: true, isAdd: false, title: `工位${row.stationNo} - 修改`, id: row.id, stationNo: row.stationNo, name: row.name, stationType: row.stationType || 'LINE', isBuiltin: !!row.isBuiltin, hasDock: !!row.hasDock, dockCode: row.dockCode || '' })
}
async function save() {
@@ -116,7 +183,7 @@ async function save() {
return ElMessage.warning(`工位${dlg.stationNo} 已存在,请换一个工位号`)
}
await request.post('/stations', {
id: dlg.id, stationNo: dlg.stationNo, name: dlg.name, stationType: dlg.stationType
id: dlg.id, stationNo: dlg.stationNo, name: dlg.name, stationType: dlg.stationType, dockCode: dlg.dockCode || ''
})
ElMessage.success(dlg.isAdd ? '工位已新增' : '已保存')
dlg.show = false
@@ -125,7 +192,7 @@ async function save() {
async function bindFlow(row, fid) {
try {
await request.post('/stations', { id: row.id, stationNo: row.stationNo, name: row.name, stationType: row.stationType || 'LINE', flowId: fid })
await request.post('/stations', { id: row.id, stationNo: row.stationNo, name: row.name, stationType: row.stationType || 'LINE', flowId: fid, dockCode: row.dockCode || '' })
ElMessage.success(fid ? '已绑定工艺流程' : '已解除绑定')
} catch {
// 拦截器已提示(如绑定到未启用的流程)
@@ -134,6 +201,17 @@ async function bindFlow(row, fid) {
}
}
async function bindDock(row, code) {
try {
await request.post('/stations', { id: row.id, stationNo: row.stationNo, name: row.name, stationType: row.stationType || 'LINE', dockCode: code })
ElMessage.success(code ? `已绑定 ${code}` : '已解绑接驳台')
} catch {
// 拦截器已提示
} finally {
await load()
}
}
async function remove(row) {
try {
await ElMessageBox.confirm(`确认删除工位${row.stationNo}${row.name}」?`, '删除工位', { type: 'warning' })
@@ -150,8 +228,63 @@ async function remove(row) {
}
}
// ---------- 工位派工(station_process ----------
const dispatch = reactive({ show: false, effectDate: '', shift: '', rows: [] })
async function openDispatch() {
dispatch.effectDate = new Date().toISOString().slice(0, 10)
dispatch.shift = ''
await loadDispatch()
dispatch.show = true
}
async function loadDispatch() {
const sp = (await request.get('/station-process', { params: { effectDate: dispatch.effectDate, shift: dispatch.shift } })) || []
const m = {}
sp.forEach((r) => { m[r.stationNo] = r.processCode })
dispatch.rows = [...stations.value]
.sort((a, b) => (a.stationNo || 0) - (b.stationNo || 0))
.map((s) => ({ stationNo: s.stationNo, name: s.name, processCode: m[s.stationNo] || 0 }))
}
// 客户端连续性校验:已派工(processCode>0)沿工位号必须单调不降、不可穿插
function validateDispatch() {
const arr = dispatch.rows.filter((r) => r.processCode > 0).sort((a, b) => a.stationNo - b.stationNo)
for (let i = 1; i < arr.length; i++) {
if (arr[i].processCode < arr[i - 1].processCode) {
return `工位${arr[i - 1].stationNo}为工序${arr[i - 1].processCode}、工位${arr[i].stationNo}为工序${arr[i].processCode}:沿工位号必须单调不降,不能穿插其他工序`
}
}
return ''
}
async function saveDispatch() {
const err = validateDispatch()
if (err) return ElMessage.warning(err)
let lastErr = ''
for (const r of dispatch.rows) {
try {
await request.post('/station-process/save', {
stationNo: r.stationNo,
processCode: r.processCode || 0,
effectDate: dispatch.effectDate,
shift: dispatch.shift
})
} catch (e) {
lastErr = (e && e.message) || '保存失败'
}
}
if (lastErr) {
ElMessage.error(lastErr)
} else {
ElMessage.success('工位派工已保存')
dispatch.show = false
}
}
onMounted(async () => {
await load()
await loadDocks()
flows.value = (await request.get('/process-flows', { params: { pageSize: 1000, page: 1 } }))?.list || []
})
</script>
+27 -1
View File
@@ -79,6 +79,19 @@
<template #default="{row}">{{ fmt(row.createdAt) }}</template>
</el-table-column>
</el-table>
<el-divider content-position="left">物料组成递归展开成品零部件到原材料</el-divider>
<el-table :data="flatBom" border size="small" row-key="_key" default-expand-all>
<el-table-column label="物料" min-width="180">
<template #default="{row}">
<span :style="{paddingLeft: (row._depth*18)+'px'}">{{ row.materialName }} <small>({{ row.materialCode }})</small></span>
</template>
</el-table-column>
<el-table-column prop="spec" label="规格" min-width="90" />
<el-table-column prop="qty" label="单台用量" width="100" />
<el-table-column label="管理方式" width="100">
<template #default="{row}">{{ row.manageMode === '2' ? '电气件SN' : '批次' }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</template>
@@ -87,7 +100,7 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { ElMessageBox } from 'element-plus'
import request from '../utils/request'
import { openPrintableHtml } from '../utils/print'
@@ -134,6 +147,19 @@ async function printCard() {
// 直接 window.open 带不上 Bearer 头会被 401,改由 axios 取回 HTML 后写入新窗口
await openPrintableHtml('/process-card', { sn: sn.value })
}
// 把递归的物料组成树扁平化为带层级缩进的列表,避免 el-table 树形 row-key 冲突
const flatBom = computed(() => {
const out = []
const walk = (nodes, depth, prefix) => {
;(nodes || []).forEach((n, i) => {
const key = prefix + '-' + n.materialCode + '-' + i
out.push(Object.assign({}, n, { _depth: depth, _key: key }))
if (n.children && n.children.length) walk(n.children, depth + 1, key)
})
}
walk(data.value?.bindTrace?.bomTree || [], 0, 'r')
return out
})
function statusText(st) {
const map = { CREATED: '已创建', ONLINE: '生产中', DONE: '已完工', SCRAPPED: '已报废' }
return map[st] || st || '-'
+6 -77
View File
@@ -42,9 +42,6 @@
<el-table-column prop="productSerial" label="产品序号" min-width="110" show-overflow-tooltip />
<el-table-column prop="quantity" label="数量" width="70" />
<el-table-column prop="finishedNum" label="已完工" width="70" />
<el-table-column label="工位组合" min-width="170" show-overflow-tooltip>
<template #default="{ row }">{{ seqText(row.processSeq) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">{{ statusText(row.status) }}</template>
</el-table-column>
@@ -80,7 +77,6 @@
<el-button size="small">更多 </el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="row.status === 'CREATED'" command="seq">工位组合</el-dropdown-item>
<el-dropdown-item v-if="can('produce.workorder:edit') && row.status === 'CREATED'" command="edit">编辑</el-dropdown-item>
<el-dropdown-item v-if="can('produce.workorder:delete') && ['CREATED','PENDING','CONFIRMED'].includes(row.status)" command="del" divided>删除</el-dropdown-item>
</el-dropdown-menu>
@@ -118,9 +114,6 @@
<el-date-picker v-model="dialog.planStart" type="date" value-format="YYYY-MM-DD"
placeholder="计划开始日期(选填)" style="width:100%" />
</el-form-item>
<el-form-item label="工位组合" required>
<ProcessSeqPicker :model-value="dialog.processSeq" @update:model-value="dialog.processSeq = $event" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog.show=false">取消</el-button>
@@ -128,25 +121,6 @@
</template>
</el-dialog>
<!-- 工位组合维护 -->
<el-dialog v-model="seq.show" :title="`维护工位组合 - ${seq.orderNo}`" width="600px">
<el-alert type="info" :closable="false"
title="勾选本工单要经过的工位(按工位号顺序执行,传送带不回走)。半成品再上线可只勾漏做的工位完成闭环。"
style="margin-bottom:14px" />
<el-form label-width="90px">
<el-form-item label="工单号"><el-input :model-value="seq.orderNo" disabled /></el-form-item>
<el-form-item label="产品编号"><el-input :model-value="seq.productCode" disabled /></el-form-item>
<el-form-item label="产品名称"><el-input :model-value="seq.productName" disabled /></el-form-item>
<el-form-item label="工位组合">
<ProcessSeqPicker :model-value="seq.processSeq" @update:model-value="seq.processSeq = $event" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="seq.show=false">取消</el-button>
<el-button v-if="can('produce.workorder:edit')" type="primary" @click="saveSeq">保存</el-button>
</template>
</el-dialog>
<!-- 自动排产引导式Item K -->
<el-dialog v-model="sched.show" :title="`自动排产 - ${sched.orderNo}`" width="640px">
<el-alert type="info" :closable="false" style="margin-bottom:14px"
@@ -201,9 +175,7 @@ import request from '../utils/request'
import { can } from '../utils/perm'
import { fmtDateOnly, fmtTime } from '../utils/format'
import PageHelp from '../components/PageHelp.vue'
import ProcessSeqPicker from '../components/ProcessSeqPicker.vue'
import { helpWorkOrder } from '../help'
import { loadStationNos } from '../utils/stations'
const statusOptions = [
{ v: 'CREATED', l: '已创建' }, { v: 'RELEASED', l: '已下发' },
@@ -232,32 +204,11 @@ function statusActions(status) {
return map[status] || []
}
function seqToCodes(s) {
if (!s) return []
const out = []
String(s).split(',').forEach((p) => {
const n = Number(p)
if (n >= 0 && !out.includes(n)) out.push(n)
})
return out.sort((a, b) => a - b)
}
function codesToSeq(codes) {
return [...codes].sort((a, b) => a - b).join(',')
}
function seqText(s) {
const codes = seqToCodes(s)
if (!codes.length) return '-'
if (stationNos.value.length && codes.length === stationNos.value.length) return '全部工位'
return codes.map((c) => `工位${c}`).join('、')
}
const query = reactive({ orderNo: '', productCode: '', productName: '', contractNo: '', projectNo: '', status: '', dueRange: null })
const list = ref([])
const productTypes = ref([])
const dialog = reactive({ show: false, id: 0, workOrderNo: '', productTypeId: 0, productCode: '', productName: '', contractNo: '', projectNo: '', productSerial: '', quantity: 1, dueDate: '', planStart: '', processSeq: '', status: 'CREATED' })
const seq = reactive({ show: false, id: 0, orderNo: '', productCode: '', productName: '', processSeq: '' })
const stationNos = ref([])
const dialog = reactive({ show: false, id: 0, workOrderNo: '', productTypeId: 0, productCode: '', productName: '', contractNo: '', projectNo: '', productSerial: '', quantity: 1, dueDate: '', planStart: '', status: 'CREATED' })
async function load() {
const [dueFrom, dueTo] = query.dueRange || []
@@ -289,15 +240,14 @@ function onProductTypeChange(id) {
}
}
function openCreate() {
Object.assign(dialog, { show: true, id: 0, workOrderNo: genOrderNo(), productTypeId: 0, productCode: '', productName: '', contractNo: '', projectNo: '', productSerial: '', quantity: 1, dueDate: '', planStart: '', processSeq: '', status: 'CREATED' })
Object.assign(dialog, { show: true, id: 0, workOrderNo: genOrderNo(), productTypeId: 0, productCode: '', productName: '', contractNo: '', projectNo: '', productSerial: '', quantity: 1, dueDate: '', planStart: '', status: 'CREATED' })
}
function openEdit(row) {
Object.assign(dialog, { show: true, id: row.id, workOrderNo: row.workOrderNo, productTypeId: row.productTypeId, productCode: row.productCode, productName: row.productName, contractNo: row.contractNo || '', projectNo: row.projectNo || '', productSerial: row.productSerial || '', quantity: row.quantity, dueDate: row.dueDate ? String(row.dueDate).slice(0, 10) : '', planStart: row.planStart ? String(row.planStart).slice(0, 10) : '', processSeq: row.processSeq || '', status: row.status })
Object.assign(dialog, { show: true, id: row.id, workOrderNo: row.workOrderNo, productTypeId: row.productTypeId, productCode: row.productCode, productName: row.productName, contractNo: row.contractNo || '', projectNo: row.projectNo || '', productSerial: row.productSerial || '', quantity: row.quantity, dueDate: row.dueDate ? String(row.dueDate).slice(0, 10) : '', planStart: row.planStart ? String(row.planStart).slice(0, 10) : '', status: row.status })
}
async function save() {
if (!dialog.dueDate) return ElMessage.warning('请选择完成日期(必填)')
if (!dialog.processSeq || !dialog.processSeq.trim()) return ElMessage.warning('请至少选择一个工位(工位组合为必填项)')
const body = { id: dialog.id, workOrderNo: dialog.workOrderNo, productTypeId: dialog.productTypeId, productCode: dialog.productCode, productName: dialog.productName, contractNo: dialog.contractNo, projectNo: dialog.projectNo, productSerial: dialog.productSerial, quantity: dialog.quantity, dueDate: dialog.dueDate, planStart: dialog.planStart, processSeq: dialog.processSeq }
const body = { id: dialog.id, workOrderNo: dialog.workOrderNo, productTypeId: dialog.productTypeId, productCode: dialog.productCode, productName: dialog.productName, contractNo: dialog.contractNo, projectNo: dialog.projectNo, productSerial: dialog.productSerial, quantity: dialog.quantity, dueDate: dialog.dueDate, planStart: dialog.planStart }
if (dialog.id) await request.put('/work-orders', body)
else await request.post('/work-orders', body)
ElMessage.success('保存成功')
@@ -305,26 +255,6 @@ async function save() {
load()
}
function openSeq(row) {
Object.assign(seq, {
show: true, id: row.id, orderNo: row.workOrderNo,
productCode: row.productCode, productName: row.productName,
processSeq: row.processSeq || ''
})
}
async function saveSeq() {
const codes = seqToCodes(seq.processSeq)
if (!codes.length) return ElMessage.warning('请至少勾选一个工位')
await request.put('/work-orders', {
id: seq.id, workOrderNo: seq.orderNo, productCode: seq.productCode,
productName: seq.productName, processSeq: codesToSeq(codes)
})
ElMessage.success('工位组合已保存')
seq.show = false
load()
}
async function changeStatus(row, status) {
const label = statusText(status)
// 取消/暂停允许填原因(可选),写入操作日志
@@ -357,8 +287,7 @@ async function del(row) {
// 操作列「更多」菜单分发:工位组合 / 编辑 / 删除
function moreAction(row, cmd) {
if (cmd === 'seq') openSeq(row)
else if (cmd === 'edit') openEdit(row)
if (cmd === 'edit') openEdit(row)
else if (cmd === 'del') del(row)
}
@@ -409,7 +338,7 @@ async function submitAutoSchedule() {
sched.show = false
}
onMounted(async () => { load(); loadProducts(); stationNos.value = await loadStationNos() })
onMounted(async () => { load(); loadProducts() })
</script>
<style scoped>