feat: 重构BOM架构,新增基础设置与虚拟工位管理功能
本次重构删除原有BOM物料清单表,改用工单工艺组合×工艺物料清单作为唯一用料来源;新增系统基础设置表支持WMS地址、日志保留天数等配置,新增虚拟工位作业管理后台接口与前端页签控制功能,同时优化工位号校验逻辑、事件日志自动清理与工单用料查询能力。
This commit is contained in:
@@ -81,26 +81,6 @@ export const helpDailyPlan = {
|
||||
]
|
||||
}
|
||||
|
||||
export const helpBom = {
|
||||
title: '产品物料清单',
|
||||
overview:
|
||||
'物料清单 = 生产 1 台该产品需要哪些物料、各用多少。\n按「产品编号 + 物料清单名称」维护:同一产品编号可并存多份物料清单(选用制)——不同厂家供货、含半成品的结构差异,都用不同的物料清单名称区分(缺省叫「默认」,不用 V1/V2 版本号)。\n工单建单时选定其中一份,之后该工单的备料算料、装机绑定校验都锁定这份物料清单,不同厂家的料单互不串。\n\n与 WMS 的关系:本页(MES)只维护「产品由哪几种料、各用几个」;每种物料叫什么、规格、按序列号还是按批次管理、库存有多少,统一在 WMS(库房)的「物料档案」里维护,两套系统互相解耦、通过图号对齐。\n\n编码对应规则:本页的「图号」必须与 WMS 物料档案里的「图号」完全一致;「管理方式」也要对应(电气件=SN 逐件,结构件=批次)。编码对不上,WMS 将无法为对应工单备料/出库。\n\n保护规则:某份物料清单一旦被工单选用过,就不允许再从中移除物料(防止历史工单追溯断链);新建/改名的物料清单名称在本页「物料清单名称」下拉直接输入即可创建。\n\n数据链路:先建「物料档案」→ 到本页选产品编号 + 命名物料清单,逐条添加物料 → 建工单时选定物料清单 → 仓库才能为对应工单领料。',
|
||||
fields: [
|
||||
{ name: '物料清单名称', source: '下拉选择/输入(可搜索,输入新名即创建)。缺省「默认」。', purpose: '同一产品编号下多份物料清单的区分名,工单建单时按此选定', fill: '如「默认」「A厂料单」「含半成品」;同一型号下名称唯一' },
|
||||
{ name: '产品编号', source: '下拉选择。来自「物料档案」里建的编码。', purpose: '要为哪个产品配置物料清单', fill: '在查询区或「添加物料」弹窗里选择产品编号' },
|
||||
{ name: '图号', source: '手工填写。必须与 WMS(库房) 物料档案的图号一致。', purpose: '该物料在库房的全库唯一编码,两系统靠它关联', fill: '如 螺丝=GJ-LUOSI-M16;图号对不上 WMS 将无法备料' },
|
||||
{ name: '物料名称', source: '手工填写。', purpose: '物料中文名,便于识别', fill: '如 十字螺丝、主控芯片' },
|
||||
{ name: '规格', source: '手工填写。', purpose: '物料规格型号', fill: '如 M16×80 / SMT-48脚' },
|
||||
{ name: '单位', source: '手工填写。', purpose: '计量单位,用于算量', fill: '个 / 件 / 套 / kg' },
|
||||
{ name: '管理方式', source: '单选。按物料类型决定,与 WMS 一致。', purpose: '电气件=逐件按SN管理;结构件=按批次管理', fill: '电气件/电子件选"序列号";螺丝等标准件选"批次"' },
|
||||
{ name: '单台用量', source: '手工填写。', purpose: '生产 1 台产品用的该物料数量', fill: '如每台 8 颗螺丝 → 填 8' },
|
||||
{ name: '损耗率%', source: '手工填写。', purpose: '考虑报废/损耗的加放比例', fill: '如 2% 填 2;0=不计算损耗' },
|
||||
{ name: '需求量', source: '自动计算 = 数量×单台用量×(1+损耗率),可覆盖。', purpose: '该物料总需求,出库不能超它', fill: '一般自动算,不必改' },
|
||||
{ name: '相关标准', source: '手工填写/导入带入。', purpose: '该物料对应的检验/工艺标准(如国标、图号标准),供质量检验与流程卡引用', fill: '如 GB/T xxx;可在导入模板中一列填入' },
|
||||
{ name: '导入', source: 'Excel 模板导入(列表页「导入」按钮)。', purpose: '批量维护物料清单,减少逐条录入', fill: '下载模板→填写→上传,系统预检(编码/管理方式/数值)并提示错误行,确认后入库' }
|
||||
]
|
||||
}
|
||||
|
||||
export const helpMaterial = {
|
||||
title: '备料单(按日排产自动算料)',
|
||||
overview:
|
||||
@@ -222,11 +202,39 @@ export const helpStation = {
|
||||
{ name: '接驳台', source: '内置数据给定(对应实体位置),不可手动更改。', purpose: '该工位位置是否有接驳台', fill: '只读' },
|
||||
{ name: '接驳台编码', source: '系统预置(种子初始化),与工位 1:1。', purpose: '该工位绑定的接驳台唯一标识', fill: '只读;由种子初始化,页面不可更改' },
|
||||
{ name: '绑定工艺', source: '本页下拉选择「启用」的工艺即完成绑定。', purpose: '该工位执行的工艺(决定工艺步骤/考核标准/PDF/物料清单);一个工位只能绑定一个工艺', fill: '在本页下拉为该工位选择启用工艺;工单执行路线由工单工艺组合决定' },
|
||||
{ name: '终端可见页签', source: '本页「页签」按钮配置(station.visibleTabs)。', purpose: '该工位终端允许显示哪些功能页签(上线建档/作业/物料/移料/工艺文件/巡检/下线完工),按工位差异化界面', fill: '点「页签」勾选;全部不勾 = 显示全部页签,保存后终端下次拉取任务生效' },
|
||||
{ name: '删除', source: '本页操作。', purpose: '删除自定义工位', fill: '仅自定义工位可删除;内置工位不允许删除' },
|
||||
{ name: '虚拟工位 0 / 13', source: '系统预置。工位号 0=上线位、13=下线位。', purpose: '产线进线口建档上线、出料口完工入库,由 PAD 操作,不连 PLC/拧紧枪', fill: '只读;上线位/下线位在工位终端(PAD)用「上线建档」「下线完工」面板操作' }
|
||||
]
|
||||
}
|
||||
|
||||
export const helpVirtualStation = {
|
||||
title: '虚拟工位作业(MES 后台人工报工)',
|
||||
overview:
|
||||
'虚拟工位 = 没有终端硬件(无屏、无 PLC、无拧紧枪)的工位(如包装等辅助工序,线下位类型)。这类工位无法用工位终端,由操作人用自己的手机/电脑登录 MES 后台,在本页完成作业:选择自己的虚拟工位 → 选工单、扫/输工件SN → 查看当前工艺步骤与应绑配料 → 逐项录入 → 点「完成报工」。\n\n与工位终端走同一套接口与校验(/station/task、/station/report):齐套校验、检测确认校验、不可倒序报工等规则完全一致;拧紧步骤因无硬件不在本页采集(由物理工位拧紧枪采集)。\n\n权限:菜单「虚拟工位作业」按账号开通(角色管理勾选 produce.virtualstation),不给普通账号开「工位绑定」等管理菜单即可。',
|
||||
fields: [
|
||||
{ name: '选择虚拟工位', source: '下拉来自「工位绑定」页维护的工位(建议给虚拟工序建线下位类型工位)。', purpose: '报工落在哪个工位(报工校验工位必须在工单工艺组合中)', fill: '选择自己负责的虚拟工位' },
|
||||
{ name: '工单', source: '今日已启用日排产且工单已下发/在制(与终端口径一致)。', purpose: '本工位今天该干哪些工单', fill: '选择当前作业的工单' },
|
||||
{ name: '工件SN', source: '上线建档时系统生成的工件唯一码(标签/二维码)。', purpose: '定位工件与所属工单,报工/绑定都挂在 SN 下', fill: '扫码或手输后回车载入' },
|
||||
{ name: '工艺步骤', source: '工位绑定工艺的步骤模板(工艺页维护)。', purpose: '本工位要做什么、考核标准是什么', fill: '手填步骤逐项录入数值/结果;「检测确认」步骤需勾选;拧紧步骤由物理工位采集,本页只读' },
|
||||
{ name: '配料', source: '工艺物料清单(flow_material)中该工艺要求装配的物料 + 已绑记录(bind-panel 接口)。', purpose: '装机绑定强校验:电气件需绑满单台用量 SN、结构件至少绑一个批次号,缺料不允许报工', fill: '在录入框填批次号/SN(多个用逗号分隔),随报工一次性提交' },
|
||||
{ name: '完成报工', source: 'POST /station/report(与终端同一接口)。', purpose: '写实绩 + 步骤考核 + 装机绑定,原子提交', fill: '全部录入后点「完成报工」;报错按提示补齐' }
|
||||
]
|
||||
}
|
||||
|
||||
export const helpSettings = {
|
||||
title: '基础设置(系统管理员)',
|
||||
overview:
|
||||
'运行参数集中配置页,保存即生效(无需重启服务)。库存查询、缺料补货、可生产套数、排产支撑均为实时计算:库存实时来自 WMS,需求按「日排产 × 工单工艺组合」现场汇总,MES 不落库存/需求表,所以改配置立刻影响下一次计算。\n\n可配置项:\n· WMS 地址(IP:端口):保存后热切换,所有库存查询/扣账/台账同步即刻走新地址;「测试连通」只试测不保存,失败不影响当前配置。\n· 需求窗口天数:缺料补货/未来需求按日排产取未来 N 天(默认 5),WMS 备料四态「预警」数据源。\n· 外推均值窗口 / 外推封顶:排产支撑在排产日耗尽仍有余量时,按近 N 天平均日需求外推,封顶防死循环。\n· 事件日志保留天数:超过保留期的操作日志每小时自动清理,0=不清理。\n\n本机基础信息(只读):主机名/内网 IP、服务监听地址、数据库、当前生效 WMS 地址、Go 版本、启动时间。\n\n安全边界:JWT 密钥、WMS Token、数据库密码等敏感信息一律留在 etc/bj_power_mes-api.yaml,不进本页(不在库里明文存密)。',
|
||||
fields: [
|
||||
{ name: 'WMS 地址', source: '手工填写(sys_config: wms.base_url),留空=用 YAML 配置/默认 127.0.0.1:9091。', purpose: '库存查询/扣账/半成品流转/台账同步的 WMS 服务地址,保存即热切换', fill: '如 http://192.168.1.100:9091;先用「测试连通」验证再保存' },
|
||||
{ name: '需求窗口天数', source: '数字(sys_config: demand.days,默认 5)。', purpose: '缺料补货/未来需求 = 未来 N 天日排产 × 工单单台含损耗用量', fill: '1~60 天;改大可提前看到更远期备料需求' },
|
||||
{ name: '外推均值窗口', source: '数字(sys_config: producible.extrap.window,默认 3)。', purpose: '排产支撑外推取最近 N 天平均日需求', fill: '1~30 天' },
|
||||
{ name: '外推封顶', source: '数字(sys_config: producible.extrap.cap,默认 365)。', purpose: '排产支撑外推最多算 N 天,防死循环', fill: '30~3650 天' },
|
||||
{ name: '事件日志保留天数', source: '数字(sys_config: eventlog.retention.days,默认 0=不清理)。', purpose: '操作日志超期自动清理(每小时检查一次)', fill: '如 180 = 保留半年;0 = 永不清理' }
|
||||
]
|
||||
}
|
||||
|
||||
export const helpDock = {
|
||||
title: '接驳台维护(只读主数据)',
|
||||
overview:
|
||||
@@ -456,9 +464,9 @@ export const helpProcessCard = {
|
||||
export const helpQuality = {
|
||||
title: '质量检验(过程检 / 完工检)',
|
||||
overview:
|
||||
'质量检验 = 对物料/产品做检验判定并留痕,分「过程检」与「完工检」两个 Tab。来料检在 WMS 库房侧完成,本页不重复。\n\n录入流程:选图号(输入图号或名称远程检索物料清单,自动带出物料名称)→ 填报检单号/生产厂家/检验单号 → 上传附件 → 判定合格/不合格 → 保存。\n\n不合格闭环:结论为「不合格」时可当场选择处置方式(退货/返修/退换)并写处置说明;也可在列表操作列「处置」补录,或点「不合格单」一键导出《不合格单.xlsx》(预填本次检验数据)。\n\n查询:图号 / 物料名称 / 报检单号 / 记录日期范围组合筛选,每个框内模糊匹配、多框取交集;记录按时间倒序,后端真分页。',
|
||||
'质量检验 = 对物料/产品做检验判定并留痕,分「过程检」与「完工检」两个 Tab。来料检在 WMS 库房侧完成,本页不重复。\n\n录入流程:选图号(输入图号或名称远程检索 WMS 物料档案,自动带出物料名称)→ 填报检单号/生产厂家/检验单号 → 上传附件 → 判定合格/不合格 → 保存。\n\n不合格闭环:结论为「不合格」时可当场选择处置方式(退货/返修/退换)并写处置说明;也可在列表操作列「处置」补录,或点「不合格单」一键导出《不合格单.xlsx》(预填本次检验数据)。\n\n查询:图号 / 物料名称 / 报检单号 / 记录日期范围组合筛选,每个框内模糊匹配、多框取交集;记录按时间倒序,后端真分页。',
|
||||
fields: [
|
||||
{ name: '图号', source: '远程检索物料清单(/quality/bom-material),也可手填。', purpose: '被检物料的图号(即 WMS 物料档案的图号)', fill: '输入图号或名称关键字选择' },
|
||||
{ name: '图号', source: '远程检索 WMS 物料档案(/materials 代理接口,WMS 是物料主数据源),也可手填。', purpose: '被检物料的图号(即 WMS 物料档案的图号)', fill: '输入图号或名称关键字选择' },
|
||||
{ name: '物料名称', source: '选图号后自动带出,可手改。', purpose: '被检物料名称', fill: '一般无需手填' },
|
||||
{ name: '报检单号', source: '系统自动生成(RQ+时间戳),可改。', purpose: '本次报检的唯一编号,也是附件挂靠键', fill: '一般无需改,须唯一' },
|
||||
{ name: '生产厂家', source: '手工填写。', purpose: '该物料的生产/供货厂家', fill: '如 XX电气有限公司' },
|
||||
@@ -523,12 +531,12 @@ export const helpAttachment = {
|
||||
export const helpProducible = {
|
||||
title: '排产支撑',
|
||||
overview:
|
||||
'把「库存还能撑多久」算清楚,给计划员排产/叫料决策用。数据来源:日排产(daily_plan)× 工单(work_order)× BOM(产品物料清单)得到需求量,库存可用量实时来自 WMS(/api/internal/stock/check)。\n\n本页两块:\n· 各成品可生产套数:只统计启用产品(停用产品不再展示产能,列表分页展示);按每个成品的 BOM 单台用量 ÷ 对应物料 WMS 可用量取最小值,即当前库存最多还能装出多少台该成品;短板说明列出限制它的物料(可用量 ÷ 单台用量 = 套数)。\n· 整体支撑:用 WMS 可用量对日排产做逐日消耗模拟,直到某物料不足,得出「还能满产多少天」「库存可支撑到哪天」「最先告罄的短板物料」;排产只排到 N 天时,第 N+1 天起用近3天平均日需求外推(封顶365天防死循环)。\n\n无排产计划时短板物料栏显示「无排产计划」;排产日全部满足且外推无有效需求时显示「充足」。本页只读,不落库。',
|
||||
'把「库存还能撑多久」算清楚,给计划员排产/叫料决策用。数据来源:日排产(daily_plan,按排产日期窗口、剔除已取消排产)× 工单(work_order)× 工单工艺组合 × 工艺物料清单(flow_material)得到需求量,库存可用量实时来自 WMS(/api/internal/stock/check)。注意:物料需求跟着工单走(同成品不同工单可用不同工艺),且一律按日排产口径计算,工单起止日期仅作参考。\n\n本页两块:\n· 各成品可生产套数:只统计有未取消日排产的工单;按该工单工艺组合的物料单台含损耗用量 ÷ 对应物料 WMS 可用量取最小值,即当前库存最多还能装出多少台;短板说明列出限制它的物料(可用量 ÷ 单台用量 = 套数)。\n· 整体支撑:用 WMS 可用量对日排产做逐日消耗模拟,直到某物料不足,得出「还能满产多少天」「库存可支撑到哪天」「最先告罄的短板物料」;排产只排到 N 天时,第 N+1 天起用近3天平均日需求外推(封顶365天防死循环)。\n\n无排产计划时短板物料栏显示「无排产计划」;排产日全部满足且外推无有效需求时显示「充足」。本页只读,不落库。',
|
||||
fields: [
|
||||
{ name: '还能满产(天)', source: '逐日消耗模拟算得(/api/v1/production/producible-support)。', purpose: '一眼看出按当前排产节奏库存能撑几天', fill: '=0 表示今天/近期排产即缺料,需立即补料' },
|
||||
{ name: '库存可支撑到', source: '支撑天数 + 今日日期推算。', purpose: '给出最晚需要到货的日期', fill: '只读;格式 YYYY-MM-DD' },
|
||||
{ name: '最先告罄短板物料', source: '模拟中第一个被耗尽的物料(名称+编码)。', purpose: '指出最该优先补货的物料', fill: '「无排产计划」/「充足」表示暂无瓶颈' },
|
||||
{ name: '可生产套数', source: '逐成品计算(只算启用产品,停用产品不展示):min(各物料可用量 ÷ 单台用量)。', purpose: '该成品当前库存能装出的最多台数', fill: '=0 表示关键物料已缺,无法开线' },
|
||||
{ name: '可生产套数', source: '逐工单计算(只算有未取消日排产的工单):min(各物料可用量 ÷ 单台含损耗用量)。', purpose: '该工单当前库存能装出的最多台数', fill: '=0 表示关键物料已缺,无法开线' },
|
||||
{ name: '短板说明', source: '限制该成品可生产套数的物料明细(可用量÷单台用量=套数)。', purpose: '定位缺哪料、缺多少', fill: '只读;多短板时最多列前3个' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Document, Calendar, Box, Goods, ShoppingCart, Monitor, Tools,
|
||||
Operation, Link, DataLine, Search, CircleCheck, Clock, User,
|
||||
Key, Fold, Expand, Warning, Paperclip, Tickets, Bell, Aim
|
||||
Key, Fold, Expand, Warning, Paperclip, Tickets, Bell, Aim, Setting
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { getUser, logout } from '../utils/auth'
|
||||
@@ -130,7 +130,6 @@ const menuGroups = [
|
||||
title: '生产管理', icon: Document, children: [
|
||||
{ code: 'produce.workorder', name: '工单管理', path: '/work-order', icon: Document },
|
||||
{ code: 'produce.dailyplan', name: '日排产', path: '/daily-plan', icon: Calendar },
|
||||
{ code: 'produce.bom', name: '产品物料清单', path: '/bom', icon: Goods },
|
||||
{ code: 'produce.producible', name: '排产支撑', path: '/producible-support', icon: DataLine },
|
||||
{ code: 'produce.material', name: '备料单', path: '/material-request', icon: ShoppingCart },
|
||||
{ code: 'produce.qty', name: '数量不符处理', path: '/qty-report', icon: Warning },
|
||||
@@ -143,6 +142,7 @@ const menuGroups = [
|
||||
{ code: 'produce.plc', name: '工位组合下发', path: '/plc-send', icon: Monitor },
|
||||
{ code: 'produce.wip', name: '在制品', path: '/wip', icon: Clock },
|
||||
{ code: 'produce.trace', name: '工件追溯', path: '/trace', icon: Search },
|
||||
{ code: 'produce.virtualstation', name: '虚拟工位作业', path: '/virtual-station', icon: Goods },
|
||||
{ code: 'produce.processcard', name: '生产流程卡', path: '/process-card', icon: Tickets },
|
||||
{ code: 'produce.torque', name: '拧紧查询', path: '/torque', icon: Tools },
|
||||
{ code: 'sys.inspect', name: '巡检终端', path: '/inspect', icon: CircleCheck }
|
||||
@@ -162,7 +162,8 @@ const menuGroups = [
|
||||
{ code: 'sys.role', name: '角色管理', path: '/role', icon: Key },
|
||||
{ code: 'sys.eventlog', name: '操作日志', path: '/event-log', icon: Clock },
|
||||
{ code: 'sys.alert', name: '预警中心', path: '/alert-center', icon: Warning },
|
||||
{ code: 'sys.attachment', name: '附件中心', path: '/attachment', icon: Paperclip }
|
||||
{ code: 'sys.attachment', name: '附件中心', path: '/attachment', icon: Paperclip },
|
||||
{ code: 'sys.settings', name: '基础设置', path: '/settings', icon: Setting }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<el-form inline>
|
||||
<el-form-item label="产品编号">
|
||||
<el-select v-model="productCode" filterable clearable placeholder="选择产品编号查看其产品物料清单" style="width:240px" @change="onProductChange">
|
||||
<el-option v-for="p in products" :key="p.code" :label="`${p.code} ${p.name || ''}`" :value="p.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="物料清单名称">
|
||||
<el-select v-model="bomName" filterable allow-create default-first-option placeholder="同一型号多份物料清单按名称区分" style="width:220px" @change="load">
|
||||
<el-option v-for="b in bomNames" :key="b.bomName" :label="`${b.bomName}(${b.itemCount}料)`" :value="b.bomName" />
|
||||
</el-select>
|
||||
<div style="color:#909399;font-size:12px;line-height:1.4;width:100%">同一型号可并存多份产品物料清单(不同厂家/含半成品),可输入新名称创建;工单建单时按名选用。</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button v-if="can('produce.bom:edit')" @click="openAdd">添加物料</el-button>
|
||||
<el-tooltip v-if="can('produce.bom:edit')" placement="bottom"
|
||||
content="模板列(首行表头):产品编号 | 物料清单名称 | 图号 | 名称 | 规格 | 单位 | 单台用量 | 损耗率 | 相关标准 | 备注。按(产品编号+物料清单名称)分组覆盖式导入。">
|
||||
<el-upload action="#" :show-file-list="false" :http-request="doImport" accept=".xlsx,.xls" style="display:inline-block;margin-left:10px">
|
||||
<el-button type="warning">导入 Excel</el-button>
|
||||
</el-upload>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="list" border size="small">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="productCode" label="产品编号" min-width="120" />
|
||||
<el-table-column prop="bomName" label="物料清单名称" width="110">
|
||||
<template #default="{row}">
|
||||
<el-tag size="small" effect="plain">{{ row.bomName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="materialCode" label="图号" min-width="120" />
|
||||
<el-table-column prop="materialName" label="物料名称" min-width="120" />
|
||||
<el-table-column prop="spec" label="规格" />
|
||||
<el-table-column prop="unit" label="单位" width="70" />
|
||||
<el-table-column prop="manageMode" label="管理方式" width="110">
|
||||
<template #default="{row}">{{ row.manageMode === '1' ? '批次' : '序列号' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unitQty" label="单台用量" width="90" />
|
||||
<el-table-column prop="lossRate" label="损耗率%" width="90" />
|
||||
<el-table-column prop="relatedStandard" label="相关标准" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{row}">{{ row.relatedStandard || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{row}">
|
||||
<el-button v-if="can('produce.bom:edit')" size="small" type="danger" @click="remove(row)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="add.show" title="添加产品物料清单" width="520px">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px"
|
||||
title="图号须与 WMS(库房)「物料档案」的图号完全一致,才能备料/出库。管理方式:电气件按 SN 逐件、结构件按批次。" />
|
||||
<el-form :model="add" label-width="90px">
|
||||
<el-form-item label="产品编号" required>
|
||||
<el-select v-model="add.productCode" filterable placeholder="选择产品编号" style="width:100%">
|
||||
<el-option v-for="p in products" :key="p.code" :label="`${p.code} ${p.name || ''}`" :value="p.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="图号" required><el-input v-model="add.materialCode" placeholder="须与 WMS 物料档案图号一致" /></el-form-item>
|
||||
<el-form-item label="物料名称"><el-input v-model="add.materialName" /></el-form-item>
|
||||
<el-form-item label="规格"><el-input v-model="add.spec" /></el-form-item>
|
||||
<el-form-item label="单位"><el-input v-model="add.unit" /></el-form-item>
|
||||
<el-form-item label="管理方式">
|
||||
<el-radio-group v-model="add.manageMode">
|
||||
<el-radio label="2">序列号</el-radio>
|
||||
<el-radio label="1">批次</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="单台用量"><el-input-number v-model="add.unitQty" :min="0" /></el-form-item>
|
||||
<el-form-item label="损耗率%"><el-input-number v-model="add.lossRate" :min="0" /></el-form-item>
|
||||
<el-form-item label="相关标准"><el-input v-model="add.relatedStandard" placeholder="检验细则/相关标准(可空)" /></el-form-item>
|
||||
<el-form-item label="需求量">
|
||||
<el-input :model-value="autoReqQty" disabled placeholder="备料时自动计算" style="width:100%" />
|
||||
<div style="color:#909399;font-size:12px;line-height:1.4;width:100%">备料需求量=单台用量 × 计划数量 × (1+损耗率%),由「按日排产生成备料」时自动计算,此处只读。</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="add.show=false">取消</el-button>
|
||||
<el-button v-if="can('produce.bom:edit')" type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
<PageHelp :data="helpBom" page="Bom" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '../utils/request'
|
||||
import { can } from '../utils/perm'
|
||||
import PageHelp from '../components/PageHelp.vue'
|
||||
import { helpBom } from '../help'
|
||||
|
||||
const productCode = ref('')
|
||||
const bomName = ref('')
|
||||
const bomNames = ref([])
|
||||
const products = ref([])
|
||||
const list = ref([])
|
||||
const add = reactive({ show: false, productCode: '', materialCode: '', materialName: '', spec: '', unit: '', manageMode: '2', unitQty: 1, lossRate: 0, relatedStandard: '' })
|
||||
// 需求量仅展示用(公式:单台用量×计划数量×(1+损耗率%)),备料生成时按工单计划数量实时计算
|
||||
const autoReqQty = computed(() => {
|
||||
if (!add.unitQty) return '0'
|
||||
return `${add.unitQty} × 计划数量 × ${(1 + (add.lossRate || 0) / 100).toFixed(2)}`
|
||||
})
|
||||
|
||||
async function loadBomNames() {
|
||||
if (!productCode.value) { bomNames.value = []; return }
|
||||
bomNames.value = (await request.get('/bom/names', { params: { productCode: productCode.value } })) || []
|
||||
if (!bomName.value && bomNames.value.length) bomName.value = bomNames.value[0].bomName
|
||||
}
|
||||
|
||||
// 切换产品:刷新该型号下并存的物料清单名称,默认选第一份
|
||||
async function onProductChange() {
|
||||
bomName.value = ''
|
||||
await loadBomNames()
|
||||
load()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!productCode.value) return ElMessage.warning('请选择产品编号')
|
||||
list.value = (await request.get('/bom', { params: { productCode: productCode.value, bomName: bomName.value || 'ALL' } })) || []
|
||||
}
|
||||
function openAdd() {
|
||||
Object.assign(add, { show: true, productCode: productCode.value || '', materialCode: '', materialName: '', spec: '', unit: '', manageMode: '2', unitQty: 1, lossRate: 0, relatedStandard: '' })
|
||||
}
|
||||
async function save() {
|
||||
if (!add.productCode) return ElMessage.warning('请选择产品编号')
|
||||
if (!add.materialCode) return ElMessage.warning('请填写图号')
|
||||
const item = { materialCode: add.materialCode, materialName: add.materialName, spec: add.spec, unit: add.unit, manageMode: add.manageMode, unitQty: add.unitQty, lossRate: add.lossRate, relatedStandard: add.relatedStandard }
|
||||
await request.put('/bom', { productCode: add.productCode, bomName: bomName.value, items: [item] })
|
||||
ElMessage.success('保存成功')
|
||||
add.show = false
|
||||
productCode.value = add.productCode
|
||||
await loadBomNames()
|
||||
load()
|
||||
}
|
||||
async function remove(row) {
|
||||
await ElMessageBox.confirm('确认移除该物料?', '提示', { type: 'warning' })
|
||||
await request.post('/bom/item/delete', { id: row.id })
|
||||
ElMessage.success('已移除')
|
||||
await loadBomNames()
|
||||
await load()
|
||||
}
|
||||
// 导入 Excel(Item I):multipart file → POST /bom/import,返回 {count}
|
||||
async function doImport(option) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', option.file)
|
||||
try {
|
||||
const res = await request.post('/bom/import', fd)
|
||||
ElMessage.success(`导入成功,共 ${res?.count || 0} 行`)
|
||||
if (option.onSuccess) option.onSuccess()
|
||||
await loadBomNames()
|
||||
load()
|
||||
} catch (e) {
|
||||
if (option.onError) option.onError(e)
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
products.value = (await request.get('/product-types')) || []
|
||||
})
|
||||
</script>
|
||||
@@ -69,7 +69,6 @@ import { helpEventLog } from '../help'
|
||||
const EVENT_FAMILIES = [
|
||||
{ label: '工单', value: 'work.order.' },
|
||||
{ label: '日排产', value: 'daily.plan.' },
|
||||
{ label: '物料清单', value: 'bom.' },
|
||||
{ label: '备料单', value: 'material.request.' },
|
||||
{ label: '工艺', value: 'process.flow.' },
|
||||
{ label: '关联工位', value: 'station.' },
|
||||
@@ -82,13 +81,13 @@ const EVENT_FAMILIES = [
|
||||
]
|
||||
const FAMILY_LABEL = Object.fromEntries(EVENT_FAMILIES.map((f) => [f.value, f.label]))
|
||||
const FAMILY_TAG = {
|
||||
'work.order.': 'primary', 'daily.plan.': 'success', 'bom.': 'warning',
|
||||
'work.order.': 'primary', 'daily.plan.': 'success',
|
||||
'material.request.': 'warning', 'process.flow.': 'info', 'station.': 'info',
|
||||
'plc.': 'danger', 'torque.': 'info', 'scan.': 'success', 'workpiece.': 'success',
|
||||
'inspection.': 'warning', 'semi.': 'info'
|
||||
}
|
||||
const ENTITY_LABEL = {
|
||||
work_order: '工单', product_bom: '物料清单', material_request: '备料单',
|
||||
work_order: '工单', material_request: '备料单',
|
||||
process_flow: '工艺', station: '工位', workpiece: '工件', torque: '拧紧',
|
||||
inspection: '检验', scan: '报工', daily_plan: '日排产', plc: '下发', semi: '半成品'
|
||||
}
|
||||
|
||||
@@ -103,11 +103,11 @@
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 物料清单 -->
|
||||
<!-- 工艺物料清单 -->
|
||||
<el-tab-pane label="物料清单" name="bom">
|
||||
<el-empty v-if="!card.bomGroups || !card.bomGroups.length" description="该产品型号暂无物料清单" />
|
||||
<el-empty v-if="!card.bomGroups || !card.bomGroups.length" description="该产品型号暂无工艺物料清单" />
|
||||
<div v-for="g in card.bomGroups" :key="g.bomName" class="blk">
|
||||
<div class="blk-t">物料清单:{{ g.bomName }}</div>
|
||||
<div class="blk-t">工艺物料清单:{{ g.bomName }}</div>
|
||||
<el-table :data="g.items" border size="small">
|
||||
<el-table-column prop="materialCode" label="图号" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="materialName" label="物料名称" min-width="150" show-overflow-tooltip />
|
||||
|
||||
@@ -51,7 +51,7 @@ onMounted(load)
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
<template #title>
|
||||
<div style="font-size:12px;line-height:1.7">
|
||||
本页基于日排产与库存可用量推算:<b>可生产套数</b> = 按各成品 BOM 单台用量 ÷ WMS 可用量取最小;
|
||||
本页基于日排产与库存可用量推算(物料需求跟着工单工艺组合走,一律按日排产口径计算):<b>可生产套数</b> = 按工单工艺组合的物料单台含损耗用量 ÷ WMS 可用量取最小;
|
||||
<b>支撑天数</b> = 用可用量对日排产做逐日消耗模拟,直到某物料不足(排产排满后,第 N+1 天起按近3天平均日需求外推,封顶365天)。
|
||||
若无排产计划或排产日全部满足,短板物料栏显示「无排产计划 / 充足」。
|
||||
</div>
|
||||
@@ -85,8 +85,8 @@ onMounted(load)
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" header="各成品可生产套数(只算启用成品;按 BOM 单台用量 ÷ 库存可用量取最小,短板物料见说明列)" v-loading="loading">
|
||||
<el-table :data="pagedItems" border size="small" empty-text="暂无成品(未配置 BOM 或物料清单)">
|
||||
<el-card shadow="never" header="各工单可生产套数(只算有未取消日排产的工单;按工艺组合单台含损耗用量 ÷ 库存可用量取最小,短板物料见说明列)" v-loading="loading">
|
||||
<el-table :data="pagedItems" border size="small" empty-text="暂无工单(无日排产或工艺未配置物料清单)">
|
||||
<el-table-column prop="productCode" label="成品编码" min-width="150" />
|
||||
<el-table-column prop="productName" label="成品名称" min-width="150">
|
||||
<template #default="{ row }">{{ row.productName || '-' }}</template>
|
||||
|
||||
@@ -71,10 +71,10 @@
|
||||
<el-form :model="dialog" label-width="100px">
|
||||
<el-form-item label="图号">
|
||||
<el-select v-model="dialog.materialCode" filterable remote reserve-keyword
|
||||
placeholder="输入图号或名称检索物料清单" :remote-method="searchBom" :loading="bomLoading"
|
||||
placeholder="输入图号或名称检索 WMS 物料档案" :remote-method="searchMaterial" :loading="bomLoading"
|
||||
style="width:100%" @change="onBomPick">
|
||||
<el-option v-for="b in bomOptions" :key="b.id"
|
||||
:label="`${b.materialCode} / ${b.materialName}`" :value="b.materialCode" />
|
||||
<el-option v-for="b in bomOptions" :key="b.code"
|
||||
:label="`${b.code} / ${b.name}`" :value="b.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="物料名称">
|
||||
@@ -218,6 +218,8 @@ const dialog = reactive({
|
||||
})
|
||||
const bomOptions = ref([])
|
||||
const bomLoading = ref(false)
|
||||
// WMS 物料档案全量缓存(/materials 代理接口,WMS 是物料主数据源);检索改为本地过滤
|
||||
const allMaterials = ref([])
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(dialog, {
|
||||
@@ -228,18 +230,24 @@ function openCreate() {
|
||||
})
|
||||
bomOptions.value = []
|
||||
}
|
||||
async function searchBom(kw) {
|
||||
async function searchMaterial(kw) {
|
||||
if (!kw) { bomOptions.value = []; return }
|
||||
bomLoading.value = true
|
||||
try {
|
||||
bomOptions.value = (await request.get('/quality/bom-material', { params: { keyword: kw } })) || []
|
||||
if (!allMaterials.value.length) {
|
||||
allMaterials.value = (await request.get('/materials')) || []
|
||||
}
|
||||
const k = kw.toLowerCase()
|
||||
bomOptions.value = allMaterials.value.filter(
|
||||
(m) => (m.code || '').toLowerCase().includes(k) || (m.name || '').toLowerCase().includes(k)
|
||||
).slice(0, 50)
|
||||
} finally {
|
||||
bomLoading.value = false
|
||||
}
|
||||
}
|
||||
function onBomPick(code) {
|
||||
const hit = bomOptions.value.find((b) => b.materialCode === code)
|
||||
if (hit) dialog.materialName = hit.materialName || dialog.materialName
|
||||
const hit = bomOptions.value.find((b) => b.code === code)
|
||||
if (hit) dialog.materialName = hit.name || dialog.materialName
|
||||
}
|
||||
async function doUpload(option) {
|
||||
const fd = new FormData()
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="14">
|
||||
<el-card shadow="never" header="可配置项(保存即生效,无需重启)">
|
||||
<el-form label-width="190px" v-loading="loading" style="max-width:640px">
|
||||
<el-form-item label="WMS 地址(IP:端口)">
|
||||
<div style="display:flex;gap:8px;width:100%">
|
||||
<el-input v-model="form.wmsBaseURL" placeholder="如 http://192.168.1.100:9091,留空=用 YAML 配置/默认" clearable />
|
||||
<el-button :loading="testing" @click="testWms">测试连通</el-button>
|
||||
</div>
|
||||
<div class="tip">保存后立即热切换,所有库存查询/扣账/台账同步即刻走新地址;测试结果:{{ testMsg || '—' }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="需求窗口天数">
|
||||
<el-input-number v-model="form.demandDays" :min="1" :max="60" />
|
||||
<div class="tip">缺料补货 / 未来需求按日排产取未来 N 天(默认 5),WMS 备料四态「预警」数据源</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="外推均值窗口(天)">
|
||||
<el-input-number v-model="form.extrapWindow" :min="1" :max="30" />
|
||||
<div class="tip">排产支撑:排产日耗尽仍有余量时,按近 N 天平均日需求外推(默认 3)</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="外推封顶(天)">
|
||||
<el-input-number v-model="form.extrapCap" :min="30" :max="3650" />
|
||||
<div class="tip">排产支撑外推最多算多少天,防死循环(默认 365)</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="事件日志保留天数">
|
||||
<el-input-number v-model="form.logRetentionDays" :min="0" :max="3650" />
|
||||
<div class="tip">超过保留期的操作日志每小时自动清理;0 = 不清理</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button v-if="can('sys.settings:edit')" type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="10">
|
||||
<el-card shadow="never" header="本机基础信息(只读)">
|
||||
<el-descriptions :column="1" border size="small" v-loading="loading">
|
||||
<el-descriptions-item label="主机名 / 内网IP">
|
||||
<div v-for="ip in info.localIPs || []" :key="ip" style="font-family:monospace">{{ ip }}</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="服务监听地址">{{ info.listenAddr || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据库">{{ info.dbInfo || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前生效 WMS 地址">{{ info.effectiveWms || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Go 版本">{{ info.goVersion || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务启动时间">{{ info.startedAt || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert type="info" :closable="false" style="margin-top:12px"
|
||||
title="JWT 密钥、WMS Token、数据库密码等敏感信息不在此页维护,统一留在 etc/bj_power_mes-api.yaml。" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<PageHelp :data="helpSettings" page="Settings" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, 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 { helpSettings } from '../help'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const testMsg = ref('')
|
||||
const form = reactive({ wmsBaseURL: '', demandDays: 5, extrapWindow: 3, extrapCap: 365, logRetentionDays: 0 })
|
||||
const info = ref({})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await request.get('/settings')
|
||||
Object.assign(form, {
|
||||
wmsBaseURL: data.wmsBaseURL || '',
|
||||
demandDays: data.demandDays ?? 5,
|
||||
extrapWindow: data.extrapWindow ?? 3,
|
||||
extrapCap: data.extrapCap ?? 365,
|
||||
logRetentionDays: data.logRetentionDays ?? 0
|
||||
})
|
||||
info.value = data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await request.put('/settings', { ...form })
|
||||
ElMessage.success('已保存并生效')
|
||||
load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testWms() {
|
||||
testing.value = true
|
||||
testMsg.value = ''
|
||||
try {
|
||||
// 只试测不保存:把输入的地址作为候选传给后端,失败不影响当前配置
|
||||
const r = await request.get('/settings/wms-test', { params: form.wmsBaseURL ? { url: form.wmsBaseURL } : {} })
|
||||
testMsg.value = typeof r === 'string' ? r : (r?.message || '连接成功')
|
||||
} catch {
|
||||
testMsg.value = ''
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tip { color: #909399; font-size: 12px; line-height: 1.6; width: 100%; }
|
||||
</style>
|
||||
@@ -44,9 +44,18 @@
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" align="center" fixed="right">
|
||||
<el-table-column label="终端可见页签" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.visibleTabs && row.visibleTabs.length">
|
||||
<el-tag v-for="t in row.visibleTabs" :key="t" size="small" style="margin-right:4px">{{ tabLabel(t) }}</el-tag>
|
||||
</template>
|
||||
<span v-else style="color:#909399">全部显示</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="can('produce.station:edit')" size="small" @click="openName(row)">改名</el-button>
|
||||
<el-button v-if="can('produce.station:edit')" size="small" @click="openTabs(row)">页签</el-button>
|
||||
<el-button v-if="can('produce.station:delete') && !row.isBuiltin" size="small" type="danger" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -83,6 +92,18 @@
|
||||
<el-button v-if="can('produce.station:edit')" type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 终端可见页签配置 -->
|
||||
<el-dialog v-model="tabDlg.show" :title="`工位${tabDlg.stationNo} - 终端可见页签`" width="480px">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px"
|
||||
title="勾选该工位终端允许显示的页签;全部不勾 = 显示全部页签。配置后终端下次拉取任务即生效。" />
|
||||
<el-checkbox-group v-model="tabDlg.tabs">
|
||||
<el-checkbox v-for="t in TAB_OPTIONS" :key="t.v" :value="t.v">{{ t.l }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<template #footer>
|
||||
<el-button @click="tabDlg.show = false">取消</el-button>
|
||||
<el-button v-if="can('produce.station:edit')" type="primary" @click="saveTabs">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
<PageHelp :data="helpStation" page="Station" />
|
||||
</template>
|
||||
@@ -98,14 +119,43 @@ import { helpStation } from '../help'
|
||||
const stations = ref([])
|
||||
const flows = ref([])
|
||||
|
||||
// 终端页签固定集合(与工位终端 Main.vue 页签一一对应)
|
||||
const TAB_OPTIONS = [
|
||||
{ v: 'online', l: '上线建档' },
|
||||
{ v: 'work', l: '作业' },
|
||||
{ v: 'material', l: '物料' },
|
||||
{ v: 'move', l: '移料' },
|
||||
{ v: 'pdf', l: '工艺文件' },
|
||||
{ v: 'inspect', l: '巡检' },
|
||||
{ v: 'offline', l: '下线完工' }
|
||||
]
|
||||
const tabLabel = (v) => (TAB_OPTIONS.find((t) => t.v === v) || {}).l || v
|
||||
|
||||
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, dockCode: s.dockCode || '' }))
|
||||
.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 || '', visibleTabs: Array.isArray(s.visibleTabs) ? s.visibleTabs : [] }))
|
||||
})
|
||||
|
||||
const dlg = reactive({ show: false, isAdd: false, title: '', id: 0, stationNo: 1, name: '', stationType: 'LINE', isBuiltin: false, hasDock: false })
|
||||
|
||||
// 终端可见页签编辑(空数组 = 显示全部)
|
||||
const tabDlg = reactive({ show: false, id: 0, stationNo: 0, name: '', stationType: 'LINE', tabs: [] })
|
||||
|
||||
function openTabs(row) {
|
||||
Object.assign(tabDlg, { show: true, id: row.id, stationNo: row.stationNo, name: row.name, stationType: row.stationType || 'LINE', tabs: [...row.visibleTabs] })
|
||||
}
|
||||
|
||||
async function saveTabs() {
|
||||
await request.post('/stations', {
|
||||
id: tabDlg.id, stationNo: tabDlg.stationNo, name: tabDlg.name,
|
||||
stationType: tabDlg.stationType, visibleTabs: tabDlg.tabs
|
||||
})
|
||||
ElMessage.success('页签配置已保存')
|
||||
tabDlg.show = false
|
||||
load()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
stations.value = (await request.get('/stations')) || []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<span style="font-weight:600">虚拟工位作业</span>
|
||||
<el-select v-model="stationNo" placeholder="选择虚拟工位" style="width:240px" @change="loadTask">
|
||||
<el-option v-for="s in stations" :key="s.id" :value="s.stationNo"
|
||||
:label="`工位${s.stationNo} ${s.name}${s.stationType === 'OFFLINE' ? '(虚拟)' : ''}`" />
|
||||
</el-select>
|
||||
<el-tag v-if="task" :type="task.flowActive ? 'success' : 'warning'" size="small">
|
||||
{{ task.flowActive ? `工艺:${task.flow?.name || '-'}` : '工位未绑定启用工艺' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px"
|
||||
title="虚拟工位无终端硬件:操作人在本页(手机/电脑均可)选择虚拟工位 → 扫/输工件SN → 查看当前步骤与配料 → 录入 → 点完成报工。与工位终端走同一套接口与校验。" />
|
||||
|
||||
<el-empty v-if="!task" description="请先选择工位" />
|
||||
<template v-else>
|
||||
<el-form inline>
|
||||
<el-form-item label="工单">
|
||||
<el-select v-model="orderNo" placeholder="选择今日排产工单" style="width:220px">
|
||||
<el-option v-for="o in orderNos" :key="o" :value="o" :label="o" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="工件SN">
|
||||
<el-input v-model="sn" placeholder="扫/输工件SN后回车" style="width:240px"
|
||||
@keyup.enter="onSnEntered" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :disabled="!sn" @click="onSnEntered">载入步骤/配料</el-button>
|
||||
<el-button type="success" :disabled="!canReport" @click="report">完成报工</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<!-- 工艺步骤 -->
|
||||
<el-col :xs="24" :md="13">
|
||||
<el-card shadow="never" header="工艺步骤(按顺序录入)">
|
||||
<el-table :data="task.steps || []" border size="small" empty-text="该工艺未配置步骤">
|
||||
<el-table-column prop="seq" label="序" width="50" align="center" />
|
||||
<el-table-column prop="name" label="步骤" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="录入" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<!-- 拧紧(自动采集)步骤:虚拟工位无拧紧枪,需在物理工位完成 -->
|
||||
<span v-if="row.collectType === 'AUTO'" style="color:#909399;font-size:12px">
|
||||
{{ row.isTorque ? '拧紧步骤(需拧紧枪,物理工位采集)' : '自动采集步骤' }}
|
||||
</span>
|
||||
<template v-else>
|
||||
<el-input v-model="stepValues[row.id]" size="small" placeholder="录入数值/结果"
|
||||
style="width:110px;margin-right:6px" />
|
||||
<el-checkbox v-if="row.needCheck" v-model="stepChecks[row.id]" size="small">检测确认</el-checkbox>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="考核标准" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ criteriaText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="stepDisplay(row).ok" type="success" size="small">合格</el-tag>
|
||||
<el-tag v-else-if="stepDisplay(row).filled" type="danger" size="small">不合格</el-tag>
|
||||
<el-tag v-else type="info" size="small">待录入</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 配料(装机绑定) -->
|
||||
<el-col :xs="24" :md="11">
|
||||
<el-card shadow="never" header="配料(装机绑定,随报工提交)">
|
||||
<el-empty v-if="!bindPanel" description="输入工件SN后载入应绑配料" />
|
||||
<template v-else-if="!bindPanel.enabled">
|
||||
<el-result icon="info" title="本工艺未配置装配物料" sub-title="无需绑定配料,可直接完成报工" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="bindPanel.required || []" border size="small">
|
||||
<el-table-column prop="materialCode" label="图号" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="materialName" label="物料" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column label="应绑/已绑" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.complete ? 'success' : 'warning'" size="small">
|
||||
{{ row.boundCount }}/{{ row.need }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本次录入(批次号/SN,多个用逗号分隔)" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="bindInputs[row.materialCode]" size="small"
|
||||
:placeholder="row.manageMode === '2' ? 'SN,逗号分隔' : '批次号'" />
|
||||
<div v-if="row.bound && row.bound.length" style="font-size:12px;color:#909399;margin-top:2px"
|
||||
:title="row.bound.join('、')">
|
||||
已绑:{{ row.bound.join('、') }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="bindPanel.missing" style="color:#f56c6c;font-size:12px;margin-top:8px">{{ bindPanel.missing }}</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
</el-card>
|
||||
<PageHelp :data="helpVirtualStation" page="VirtualStation" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '../utils/request'
|
||||
import PageHelp from '../components/PageHelp.vue'
|
||||
import { helpVirtualStation } from '../help'
|
||||
|
||||
const stations = ref([])
|
||||
const stationNo = ref(null)
|
||||
const task = ref(null)
|
||||
const orderNo = ref('')
|
||||
const sn = ref('')
|
||||
const bindPanel = ref(null)
|
||||
const stepValues = reactive({})
|
||||
const stepChecks = reactive({})
|
||||
const bindInputs = reactive({})
|
||||
const startedAt = ref('')
|
||||
|
||||
const orderNos = computed(() => (Array.isArray(task.value?.orderNos) ? task.value.orderNos : []))
|
||||
|
||||
// 可报工:选了工单+SN、工艺已绑定;拧紧步骤不拦(在物理工位采集)
|
||||
const manualSteps = computed(() => (task.value?.steps || []).filter((s) => s.collectType === 'MANUAL'))
|
||||
const canReport = computed(() => {
|
||||
if (!stationNo.value || !sn.value || !orderNo.value) return false
|
||||
if (!task.value?.flowActive) return false
|
||||
const missing = manualSteps.value.some((s) => stepValues[s.id] === undefined || stepValues[s.id] === '')
|
||||
return !missing
|
||||
})
|
||||
|
||||
function criteriaText(step) {
|
||||
const arr = step.criteria || []
|
||||
if (!arr.length) return '-'
|
||||
return arr.map((c) => {
|
||||
if (c.logic === 'RANGE') return `${c.name}: ${c.min ?? ''}~${c.max ?? ''}${c.unit || ''}`
|
||||
if (c.logic === 'GE') return `${c.name} ≥ ${c.target}${c.unit || ''}`
|
||||
if (c.logic === 'LE') return `${c.name} ≤ ${c.target}${c.unit || ''}`
|
||||
return `${c.name} ${c.logic} ${c.target}`
|
||||
}).join(';')
|
||||
}
|
||||
|
||||
// 步骤判定:有考核标准按标准,无标准录入即合格
|
||||
function criterionOk(c, v) {
|
||||
const n = Number(v)
|
||||
if (isNaN(n)) return false
|
||||
if (c.logic === 'GE') return n >= Number(c.target)
|
||||
if (c.logic === 'LE') return n <= Number(c.target)
|
||||
if (c.logic === 'GT') return n > Number(c.target)
|
||||
if (c.logic === 'LT') return n < Number(c.target)
|
||||
if (c.logic === 'EQUAL') return n === Number(c.target)
|
||||
if (c.logic === 'RANGE') {
|
||||
if (c.min != null && n < Number(c.min)) return false
|
||||
if (c.max != null && n > Number(c.max)) return false
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
function stepDisplay(step) {
|
||||
const v = stepValues[step.id]
|
||||
const filled = v !== undefined && v !== null && v !== ''
|
||||
if (!filled) return { filled: false, ok: false }
|
||||
const crits = step.criteria || []
|
||||
if (crits.length) return { filled: true, ok: crits.every((c) => criterionOk(c, v)) }
|
||||
return { filled: true, ok: true }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
stations.value = (await request.get('/stations')) || []
|
||||
}
|
||||
|
||||
async function loadTask() {
|
||||
task.value = null
|
||||
bindPanel.value = null
|
||||
Object.keys(stepValues).forEach((k) => delete stepValues[k])
|
||||
Object.keys(stepChecks).forEach((k) => delete stepChecks[k])
|
||||
if (stationNo.value === null || stationNo.value === '') return
|
||||
try {
|
||||
task.value = await request.get('/station/task', { params: { stationNo: stationNo.value } })
|
||||
if (orderNos.value.length && !orderNos.value.includes(orderNo.value)) {
|
||||
orderNo.value = orderNos.value[0]
|
||||
}
|
||||
} catch {
|
||||
task.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// onSnEntered 载入该工件的配料面板(步骤录入本地进行)
|
||||
async function onSnEntered() {
|
||||
if (!sn.value) return
|
||||
if (!startedAt.value) startedAt.value = new Date().toISOString()
|
||||
bindPanel.value = null
|
||||
Object.keys(bindInputs).forEach((k) => delete bindInputs[k])
|
||||
const flowId = Number(task.value?.flowId || 0)
|
||||
if (!flowId) return
|
||||
try {
|
||||
bindPanel.value = await request.get('/workpiece/bind-panel', { params: { sn: sn.value, flowId } })
|
||||
} catch {
|
||||
bindPanel.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// report 完成报工:步骤数据 + 装机绑定 随单提交(与终端同一接口 /station/report)
|
||||
async function report() {
|
||||
const steps = manualSteps.value.map((s) => ({
|
||||
stepId: s.id, name: s.name,
|
||||
value: Number(stepValues[s.id]) || 0,
|
||||
text: String(stepValues[s.id] ?? ''),
|
||||
checked: !!stepChecks[s.id]
|
||||
}))
|
||||
const binds = []
|
||||
if (bindPanel.value?.enabled) {
|
||||
for (const r of bindPanel.value.required || []) {
|
||||
const raw = String(bindInputs[r.materialCode] || '').trim()
|
||||
if (!raw) continue
|
||||
for (const v of raw.split(/[,,;;\s]+/).filter(Boolean)) {
|
||||
binds.push({ materialCode: r.materialCode, bindValue: v })
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await request.post('/station/report', {
|
||||
sn: sn.value, flowId: Number(task.value.flowId || 0), stationNo: stationNo.value,
|
||||
steps, binds, startedAt: startedAt.value, endedAt: new Date().toISOString()
|
||||
})
|
||||
ElMessage.success('报工成功')
|
||||
sn.value = ''
|
||||
startedAt.value = ''
|
||||
bindPanel.value = null
|
||||
Object.keys(bindInputs).forEach((k) => delete bindInputs[k])
|
||||
await onSnEntered()
|
||||
} catch {
|
||||
// 拦截器已提示(缺料/倒序/状态不符等)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -57,8 +57,9 @@
|
||||
<el-table-column label="开始时间" width="110">
|
||||
<template #default="{ row }">{{ fmtDateOnly(row.planStart) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<el-table-column label="操作" width="350" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openUsage(row)">用料</el-button>
|
||||
<el-tooltip v-if="can('produce.workorder:dailyplan') && !['DONE','CANCELLED'].includes(row.status)"
|
||||
:disabled="hasCombination(row)" content="请先选择工艺组合" placement="top">
|
||||
<span style="margin-right:12px">
|
||||
@@ -187,6 +188,27 @@
|
||||
<el-button v-if="can('produce.workorder:dailyplan')" type="primary" @click="submitAutoSchedule">确认排产</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 工单单台用料汇总(只读:工单工艺组合 × 工艺物料清单 实时汇总,不落库) -->
|
||||
<el-dialog v-model="usage.show" :title="`单台用料汇总 - ${usage.orderNo}`" width="820px">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px"
|
||||
title="物料跟着工单走:按该工单的工艺组合 × 各工艺的物料清单实时汇总(同成品不同工单可用不同工艺)。单台含损耗用量 = Σ 各行单台用量 × (1+损耗率%),是备料/反冲的计算口径。" />
|
||||
<el-table :data="usage.rows" border size="small" v-loading="usage.loading"
|
||||
empty-text="该工单工艺组合未配置物料清单">
|
||||
<el-table-column prop="materialCode" label="图号" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="materialName" label="物料名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="spec" label="规格" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="unit" label="单位" width="70" align="center" />
|
||||
<el-table-column label="管理方式" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.manageMode === '2' ? 'SN(电气件)' : '批次(结构件)' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unitQty" label="单台净用量" width="100" align="center" />
|
||||
<el-table-column prop="lossRate" label="损耗率(%)" width="100" align="center" />
|
||||
<el-table-column prop="effQty" label="单台含损耗" width="110" align="center" />
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="usage.show = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<PageHelp :data="helpWorkOrder" page="WorkOrder" />
|
||||
</template>
|
||||
@@ -286,6 +308,17 @@ async function loadComboData() {
|
||||
} catch { flows.value = [] }
|
||||
}
|
||||
|
||||
// ---------- 工单单台用料汇总(只读) ----------
|
||||
const usage = reactive({ show: false, orderNo: '', rows: [], loading: false })
|
||||
async function openUsage(row) {
|
||||
Object.assign(usage, { show: true, orderNo: row.workOrderNo, rows: [], loading: true })
|
||||
try {
|
||||
usage.rows = (await request.get('/order-usage', { params: { orderNo: row.workOrderNo } })) || []
|
||||
} finally {
|
||||
usage.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = reactive({ show: false, id: 0, workOrderNo: '', productTypeId: 0, productCode: '', productName: '', contractNo: '', projectNo: '', productSerial: '', quantity: 1, dueDate: '', planStart: '', status: 'CREATED' })
|
||||
|
||||
async function load() {
|
||||
|
||||
@@ -10,7 +10,6 @@ const routes = [
|
||||
{ path: '', redirect: '/work-order' },
|
||||
{ path: 'work-order', name: 'WorkOrder', component: () => import('../pages/WorkOrder.vue'), meta: { title: '工单管理' } },
|
||||
{ path: 'daily-plan', name: 'DailyPlan', component: () => import('../pages/DailyPlan.vue'), meta: { title: '日排产' } },
|
||||
{ path: 'bom', name: 'Bom', component: () => import('../pages/Bom.vue'), meta: { title: '产品物料清单' } },
|
||||
{ path: 'producible-support', name: 'ProducibleSupport', component: () => import('../pages/ProducibleSupport.vue'), meta: { title: '排产支撑' } },
|
||||
{ path: 'material-request', name: 'MaterialRequest', component: () => import('../pages/MaterialRequest.vue'), meta: { title: '备料单' } },
|
||||
{ path: 'qty-report', name: 'QtyReport', component: () => import('../pages/QtyReport.vue'), meta: { title: '数量不符处理' } },
|
||||
@@ -24,6 +23,8 @@ const routes = [
|
||||
{ path: 'quality', name: 'QualityInspection', component: () => import('../pages/QualityInspection.vue'), meta: { title: '质量检验' } },
|
||||
{ path: 'wip', name: 'Wip', component: () => import('../pages/Wip.vue'), meta: { title: '在制品' } },
|
||||
{ path: 'trace', name: 'Trace', component: () => import('../pages/Trace.vue'), meta: { title: '工件追溯' } },
|
||||
{ path: 'virtual-station', name: 'VirtualStation', component: () => import('../pages/VirtualStation.vue'), meta: { title: '虚拟工位作业' } },
|
||||
{ path: 'settings', name: 'Settings', component: () => import('../pages/Settings.vue'), meta: { title: '基础设置' } },
|
||||
{ path: 'process-card', name: 'ProcessCard', component: () => import('../pages/ProcessCard.vue'), meta: { title: '生产流程卡' } },
|
||||
{ path: 'product-type', name: 'ProductType', component: () => import('../pages/ProductType.vue'), meta: { title: '物料档案' } },
|
||||
{ path: 'event-log', name: 'EventLog', component: () => import('../pages/EventLog.vue'), meta: { title: '操作日志' } },
|
||||
|
||||
Reference in New Issue
Block a user