feat: 重构BOM架构,新增基础设置与虚拟工位管理功能

本次重构删除原有BOM物料清单表,改用工单工艺组合×工艺物料清单作为唯一用料来源;新增系统基础设置表支持WMS地址、日志保留天数等配置,新增虚拟工位作业管理后台接口与前端页签控制功能,同时优化工位号校验逻辑、事件日志自动清理与工单用料查询能力。
This commit is contained in:
SunYF
2026-09-23 11:41:28 +08:00
parent d3eda6b588
commit f274da044c
74 changed files with 3637 additions and 5586 deletions
+32 -24
View File
@@ -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% 填 20=不计算损耗' },
{ 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 }
]
}
]
-165
View File
@@ -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()
}
// 导入 ExcelItem 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>
+2 -3
View File
@@ -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 默认 5WMS 备料四态预警数据源</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>
+52 -2
View File
@@ -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>
+34 -1
View File
@@ -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() {
+2 -1
View File
@@ -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: '操作日志' } },