refactor: 统一术语BOM为物料清单并补充工位扩展能力

主要变更:
1.  后端:重构工位管理逻辑,新增工位自定义扩展能力,将工位号上限调整为999并支持14+虚拟工位
2.  前后端:全量替换"BOM"术语为"物料清单",包括提示语、注释和文档
3.  WMS模块:新增AGV任务取消接口与前端页面,对接海康RCS取消任务逻辑
4.  权限与种子数据:新增工位新增权限并更新权限配置
5.  修复错误校验逻辑,优化AGV客户端签名与请求处理
This commit is contained in:
SunYF
2026-09-17 19:41:00 +08:00
parent e31b85e87a
commit bf3a408839
20 changed files with 276 additions and 52 deletions
+3 -3
View File
@@ -389,7 +389,7 @@ export const helpInspect = {
export const helpProcessCard = {
title: '生产流程卡(产品型号级全景流程卡)',
overview:
'这个菜单做什么用:按**产品型号**全方位展示该型号的完整制造定义,用于工艺交底、审核与客户查阅;与「工件追溯」页里按单个 SN 打印的流程卡互补——后者记的是“这一件实际怎么做的”,本页讲的是“这个型号应该怎么做”。\n\n内容构成(页面按页签展示,打印时全部输出):①工位流程总览——工位组合、每工位绑定的工艺流程、流程状态、步骤数、检验点数、拧紧步骤数、工艺图纸、该型号的历史作业时间;②工艺流程与步骤——逐工位列出每条步骤的采集方式(手工录入/自动采集/无需采集)、是否需检测确认、是否拧紧采集、考核标准(含上下限);③物料清单——该型号的 BOM 明细,按 BOM 名称分组,含图号/名称/规格/单位/管理方式/装配工位/单台用量/损耗率/需求总量;④检验卡项——把所有「需要检测确认」的步骤汇总成检验项清单,含判定标准。\n\n工位组合从哪来:优先取该产品编号最近一张工单的「工位组合」;若该型号还没有工单,则按全产线工位顺序(升序)展示,页面顶部会标注来源。\n\n打印/导出:点「打印 / 导出 PDF」,系统会在新窗口打开排版好的 A4 卡片版式,用浏览器打印即可(选择“另存为 PDF”即导出电子档)。',
'这个菜单做什么用:按**产品型号**全方位展示该型号的完整制造定义,用于工艺交底、审核与客户查阅;与「工件追溯」页里按单个 SN 打印的流程卡互补——后者记的是“这一件实际怎么做的”,本页讲的是“这个型号应该怎么做”。\n\n内容构成(页面按页签展示,打印时全部输出):①工位流程总览——工位组合、每工位绑定的工艺流程、流程状态、步骤数、检验点数、拧紧步骤数、工艺图纸、该型号的历史作业时间;②工艺流程与步骤——逐工位列出每条步骤的采集方式(手工录入/自动采集/无需采集)、是否需检测确认、是否拧紧采集、考核标准(含上下限);③物料清单——该型号的物料清单明细,按物料清单名称分组,含图号/名称/规格/单位/管理方式/装配工位/单台用量/损耗率/需求总量;④检验卡项——把所有「需要检测确认」的步骤汇总成检验项清单,含判定标准。\n\n工位组合从哪来:优先取该产品编号最近一张工单的「工位组合」;若该型号还没有工单,则按全产线工位顺序(升序)展示,页面顶部会标注来源。\n\n打印/导出:点「打印 / 导出 PDF」,系统会在新窗口打开排版好的 A4 卡片版式,用浏览器打印即可(选择“另存为 PDF”即导出电子档)。',
fields: [
{ name: '产品型号', source: '下拉选择,来自「产品型号」页。', purpose: '要查看哪个型号的全景流程', fill: '选定后自动带出产品编号' },
{ name: '产品编号', source: '自动带出(也可手工填写,直接按编号生成)。', purpose: '匹配工单、物料清单的关键字', fill: '一般无需手填' },
@@ -421,9 +421,9 @@ export const helpQtyReport = {
export const helpQuality = {
title: '质量检验(过程检 / 完工检)',
overview:
'质量检验 = 对物料/产品做检验判定并留痕,分「过程检」与「完工检」两个 Tab。来料检在 WMS 库房侧完成,本页不重复。\n\n录入流程:选图号(输入图号或名称远程检索 BOM 料,自动带出物料名称)→ 填报检单号/生产厂家/检验单号 → 上传附件 → 判定合格/不合格 → 保存。\n\n不合格闭环:结论为「不合格」时可当场选择处置方式(退货/返修/退换)并写处置说明;也可在列表操作列「处置」补录,或点「不合格单」一键导出《不合格单.xlsx》(预填本次检验数据)。\n\n查询:图号 / 物料名称 / 报检单号 / 记录日期范围组合筛选,每个框内模糊匹配、多框取交集;记录按时间倒序,后端真分页。',
'质量检验 = 对物料/产品做检验判定并留痕,分「过程检」与「完工检」两个 Tab。来料检在 WMS 库房侧完成,本页不重复。\n\n录入流程:选图号(输入图号或名称远程检索物料清单,自动带出物料名称)→ 填报检单号/生产厂家/检验单号 → 上传附件 → 判定合格/不合格 → 保存。\n\n不合格闭环:结论为「不合格」时可当场选择处置方式(退货/返修/退换)并写处置说明;也可在列表操作列「处置」补录,或点「不合格单」一键导出《不合格单.xlsx》(预填本次检验数据)。\n\n查询:图号 / 物料名称 / 报检单号 / 记录日期范围组合筛选,每个框内模糊匹配、多框取交集;记录按时间倒序,后端真分页。',
fields: [
{ name: '图号', source: '远程检索 BOM 料/quality/bom-material),也可手填。', purpose: '被检物料的图号(即 WMS 物料档案的图号)', fill: '输入图号或名称关键字选择' },
{ name: '图号', source: '远程检索物料清单/quality/bom-material),也可手填。', purpose: '被检物料的图号(即 WMS 物料档案的图号)', fill: '输入图号或名称关键字选择' },
{ name: '物料名称', source: '选图号后自动带出,可手改。', purpose: '被检物料名称', fill: '一般无需手填' },
{ name: '报检单号', source: '系统自动生成(RQ+时间戳),可改。', purpose: '本次报检的唯一编号,也是附件挂靠键', fill: '一般无需改,须唯一' },
{ name: '生产厂家', source: '手工填写。', purpose: '该物料的生产/供货厂家', fill: '如 XX电气有限公司' },
@@ -107,7 +107,7 @@
<el-tab-pane label="物料清单" name="bom">
<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">BOM{{ 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 />
@@ -71,7 +71,7 @@
<el-form :model="dialog" label-width="100px">
<el-form-item label="图号">
<el-select v-model="dialog.materialCode" filterable remote reserve-keyword
placeholder="输入图号或名称检索 BOM 料" :remote-method="searchBom" :loading="bomLoading"
placeholder="输入图号或名称检索物料清单" :remote-method="searchBom" :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" />
+42 -8
View File
@@ -1,11 +1,23 @@
<template>
<el-card>
<el-alert type="info" :closable="false" style="margin-bottom:12px"
title="工位数以数据库为准(仅改名称,不增删工位)。本页为工位总览,每个工位的工艺流程在「关联工位」页维护:在下拉选择启用的工艺流程即绑定到该工位;一个工位只能有一个启用的工艺流程。" />
title="工位数以数据库为准。本页可新增工位(工位号自定义,可扩展到 14、15…)与改名;每个工位的工艺流程在「关联工位」页维护:在下拉选择启用的工艺流程即绑定到该工位;一个工位只能有一个启用的工艺流程。" />
<div style="margin-bottom:12px">
<el-button v-if="can('produce.station:add')" type="primary" @click="openAdd">新增工位</el-button>
<span style="margin-left:12px;color:#909399;font-size:12px">虚拟工位 0 上线位13 下线位类型选线下位不连 PLC仅记录上下线</span>
</div>
<el-table :data="rows" border size="small">
<el-table-column prop="stationNo" label="工位号" width="90" align="center" />
<el-table-column prop="name" label="工位名称" min-width="170" show-overflow-tooltip />
<el-table-column label="类型" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.stationType === 'OFFLINE' ? 'info' : 'success'">
{{ row.stationType === 'OFFLINE' ? '线下位' : '线上位' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定工艺流程" min-width="240">
<template #default="{ row }">
<el-select :model-value="row.flowId" size="small" placeholder="未绑定" @change="(v) => bindFlow(row, v)" style="width:100%">
@@ -21,11 +33,23 @@
</el-table-column>
</el-table>
<el-dialog v-model="dlg.show" :title="`工位${dlg.stationNo} - 修改名称`" width="420px">
<el-dialog v-model="dlg.show" :title="dlg.title" width="440px">
<el-form :model="dlg" label-width="90px">
<el-form-item label="工位号" required>
<el-input-number v-model="dlg.stationNo" :min="0" :max="999" :disabled="!dlg.isAdd" style="width:100%" />
<div v-if="dlg.isAdd" style="color:#909399;font-size:12px;line-height:1.5;margin-top:4px">
自定义工位号可与现有工位不连续 14150 为上线位13 为下线位以外的编号均按物理/虚拟位识别
</div>
</el-form-item>
<el-form-item label="工位名称" required>
<el-input v-model="dlg.name" placeholder="如 一号装配工位" />
</el-form-item>
<el-form-item label="工位类型">
<el-radio-group v-model="dlg.stationType">
<el-radio label="LINE">线上位 PLC</el-radio>
<el-radio label="OFFLINE">线下位虚拟不连 PLC</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dlg.show = false">取消</el-button>
@@ -50,32 +74,42 @@ const flows = ref([])
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 }))
.map((s) => ({ id: s.id, stationNo: s.stationNo, name: s.name, flowId: s.flowId, flowName: s.flowName, stationType: s.stationType }))
})
const dlg = reactive({ show: false, id: 0, stationNo: 1, name: '' })
const dlg = reactive({ show: false, isAdd: false, title: '', id: 0, stationNo: 1, name: '', stationType: 'LINE' })
async function load() {
stations.value = (await request.get('/stations')) || []
}
function openAdd() {
const used = new Set(stations.value.map((s) => s.stationNo))
let next = 14
while (used.has(next)) next++
Object.assign(dlg, { show: true, isAdd: true, title: '新增工位', id: 0, stationNo: next, name: '', stationType: 'LINE' })
}
function openName(row) {
Object.assign(dlg, { show: true, id: row.id, stationNo: row.stationNo, name: row.name })
Object.assign(dlg, { show: true, isAdd: false, title: `工位${row.stationNo} - 修改`, id: row.id, stationNo: row.stationNo, name: row.name, stationType: row.stationType || 'LINE' })
}
async function save() {
if (!dlg.name) return ElMessage.warning('请输入工位名称')
if (dlg.isAdd && stations.value.some((s) => s.stationNo === dlg.stationNo)) {
return ElMessage.warning(`工位${dlg.stationNo} 已存在,请换一个工位号`)
}
await request.post('/stations', {
id: dlg.id, stationNo: dlg.stationNo, name: dlg.name
id: dlg.id, stationNo: dlg.stationNo, name: dlg.name, stationType: dlg.stationType
})
ElMessage.success('已保存')
ElMessage.success(dlg.isAdd ? '工位已新增' : '已保存')
dlg.show = false
load()
}
async function bindFlow(row, fid) {
try {
await request.post('/stations', { id: row.id, stationNo: row.stationNo, name: row.name, flowId: fid })
await request.post('/stations', { id: row.id, stationNo: row.stationNo, name: row.name, stationType: row.stationType || 'LINE', flowId: fid })
ElMessage.success(fid ? '已绑定工艺流程' : '已解除绑定')
} catch {
// 拦截器已提示(如绑定到未启用的流程)
+2 -1
View File
@@ -18,6 +18,7 @@ import (
"bj_power_mes/ent/workorder"
"bj_power_mes/ent/workpiece"
"bj_power_mes/ent/workpieceprocess"
"bj_power_mes/internal/logic"
"bj_power_mes/internal/svc"
)
@@ -237,7 +238,7 @@ func parseCardSeq(s string) []int {
continue
}
var n int
if _, err := fmt.Sscanf(p, "%d", &n); err == nil && n >= 1 && n <= 12 {
if _, err := fmt.Sscanf(p, "%d", &n); err == nil && n >= 1 && n <= logic.ProcessSeqMaxStation {
out = append(out, n)
}
}
@@ -86,7 +86,7 @@ func ImportBomHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
count++
}
if count == 0 {
httpx.Fail(w, 3111, "未解析到有效 BOM 行(至少需产品编号与物料编码")
httpx.Fail(w, 3111, "未解析到有效物料清单行(至少需产品编号与图号")
return
}
for _, k := range order {
+39 -3
View File
@@ -278,14 +278,50 @@ func (s *Service) ListStations(ctx context.Context) ([]*StationVO, error) {
return out, nil
}
// SaveStation 维护工位:只改名称/绑定流程。工位主数据由数据库维护,本接口不新增工位。
// SaveStation 维护工位:
// - 工位号已存在 → 只改名称/类型/绑定流程(不增删工位)
// - 工位号不存在 → 新增工位;新增的工位号由用户自定义,允许扩展 14、15…(虚拟/物理均可)
//
// 工位数量以数据库为准,前端下拉随 station 表自动增减;虚拟工位(0/13/14...)不连 PLC、仅记录。
func (s *Service) SaveStation(ctx context.Context, req StationReq, operator string) error {
if req.StationNo <= 0 {
if req.StationNo < 0 {
return errors.New("工位号非法(需 ≥ 0")
}
if req.StationNo == 0 && req.Name == "" {
return errors.New("工位号非法")
}
st, err := s.ctx.EntClient.Station.Query().Where(station.StationNo(req.StationNo)).First(ctx)
if err != nil {
return fmt.Errorf("工位 %d 不存在,请先在工位主数据中维护", req.StationNo)
// 新增工位:工位号不存在即创建
if req.Name == "" {
return errors.New("请填写工位名称")
}
stype := "LINE"
if req.StationType != nil {
stype = *req.StationType
if stype != "LINE" && stype != "OFFLINE" {
return errors.New("工位类型仅支持 LINE/OFFLINE")
}
}
cr := s.ctx.EntClient.Station.Create().
SetStationNo(req.StationNo).SetName(req.Name).SetStationType(stype).SetStatus("ENABLED")
if req.FlowId != nil && *req.FlowId > 0 {
flow, fErr := s.ctx.EntClient.ProcessFlow.Get(ctx, *req.FlowId)
if fErr != nil {
return errors.New("所选工艺流程不存在")
}
if flow.Status != "ACTIVE" {
return errors.New("该工艺流程已停用,请先启用后再绑定工位")
}
cr.SetFlowId(*req.FlowId)
}
created, cErr := cr.Save(ctx)
if cErr != nil {
return cErr
}
s.ctx.EventLog.Write(ctx, "station.create", "", operator, "station", "",
"新增工位", map[string]any{"stationNo": created.StationNo, "name": created.Name})
return nil
}
// 仅改名称:flowId 不传(nil)保留原绑定;0=解绑;>0=绑定指定启用流程
upd := s.ctx.EntClient.Station.UpdateOneID(st.ID).SetName(req.Name)
+6 -2
View File
@@ -10,8 +10,12 @@ import (
// FullProcessSeq 全部 12 个工位的完整组合(按顺序执行)
const FullProcessSeq = "1,2,3,4,5,6,7,8,9,10,11,12"
// ProcessSeqMaxStation 工位组合允许的最大工位号(工位主数据可自行扩展,故放宽上限)。
const ProcessSeqMaxStation = 999
// ParseProcessSeq 解析工位组合字符串为升序去重的工位码列表。
// 唯一格式:逗号分隔 "1,3,5" / "10,11,12"。传送带不回走,故组合天然按工位号升序执行。
// 工位号取值 1..ProcessSeqMaxStation:工位主数据可扩展到 14、15…(虚拟工位仅记录,不连 PLC)。
func ParseProcessSeq(s string) []int {
s = strings.TrimSpace(s)
if s == "" {
@@ -25,7 +29,7 @@ func ParseProcessSeq(s string) []int {
continue
}
n, err := strconv.Atoi(p)
if err != nil || n < 1 || seen[n] {
if err != nil || n < 1 || n > ProcessSeqMaxStation || seen[n] {
continue
}
seen[n] = true
@@ -48,7 +52,7 @@ func NormalizeProcessSeq(s string) string {
return strings.Join(parts, ",")
}
// ValidateProcessSeq 校验工位组合:非空、1~12 升序执行
// ValidateProcessSeq 校验工位组合:非空、按工位号升序执行(工位号上限见 ProcessSeqMaxStation
func ValidateProcessSeq(s string) error {
if len(ParseProcessSeq(s)) == 0 {
return errors.New("工位组合不能为空,请至少选择一个工位")
+4 -4
View File
@@ -33,7 +33,7 @@ func (s *Service) Seed(ctx context.Context) error {
"produce.qtyreport", "produce.quality", "produce.quality:edit",
"produce.plc:send", "produce.product:add", "produce.product:edit", "produce.product:delete",
"produce.processflow:add", "produce.processflow:edit", "produce.processflow:delete",
"produce.processflow:upload", "produce.station:edit",
"produce.processflow:upload", "produce.station:edit", "produce.station:add",
}
} else if r.code == "INSPECTOR" {
codes = []string{"produce.trace", "produce.wip", "produce.torque", "produce.performance", "sys.eventlog",
@@ -74,8 +74,7 @@ func (s *Service) Seed(ctx context.Context) error {
{"produce.plc", "工位组合下发", "MENU", "/plc-send"},
{"produce.torque", "拧紧查询", "MENU", "/torque"},
{"produce.processflow", "工艺流程", "MENU", "/process-flow"},
{"produce.station", "关联工位", "MENU", "/station"},
{"produce.performance", "绩效报表", "MENU", "/performance"},
{"produce.station", "关联工位", "MENU", "/station"}, {"produce.performance", "绩效报表", "MENU", "/performance"},
{"produce.scan", "手动报工", "MENU", "/scan"},
{"produce.trace", "工件追溯", "MENU", "/trace"},
{"produce.wip", "在制品", "MENU", "/wip"},
@@ -115,7 +114,8 @@ func (s *Service) Seed(ctx context.Context) error {
{"produce.processflow:edit", "工艺流程-编辑", "BUTTON", "", "produce.processflow"},
{"produce.processflow:delete", "工艺流程-删除", "BUTTON", "", "produce.processflow"},
{"produce.processflow:upload", "工艺流程-上传图纸", "BUTTON", "", "produce.processflow"},
{"produce.station:edit", "工位-绑定", "BUTTON", "", "produce.station"},
{"produce.station:edit", "工位-绑定与改名", "BUTTON", "", "produce.station"},
{"produce.station:add", "工位-新增", "BUTTON", "", "produce.station"},
{"sys.account:add", "账号-新增", "BUTTON", "", "sys.account"},
{"sys.account:edit", "账号-编辑", "BUTTON", "", "sys.account"},
{"sys.account:delete", "账号-删除", "BUTTON", "", "sys.account"},
+1 -1
View File
@@ -6,7 +6,7 @@
<title>MES 产线控制</title>
<!-- 系统图标:产线齿轮(内联 SVG,避免依赖外部文件) -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231668dc'/><circle cx='16' cy='16' r='4.2' fill='none' stroke='%23fff' stroke-width='2'/><path d='M16 5v3M16 24v3M5 16h3M24 16h3M8.2 8.2l2.1 2.1M21.7 21.7l2.1 2.1M23.8 8.2l-2.1 2.1M10.3 21.7l-2.1 2.1' stroke='%23fff' stroke-width='2' stroke-linecap='round'/></svg>">
<script type="module" crossorigin src="./assets/index-BlmM93M7.js"></script>
<script type="module" crossorigin src="./assets/index-DENbNV0h.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CuDvktnz.css">
</head>
<body>
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"entgo.io/ent/schema/index"
)
// BomItem 产品 BOM 明细(一个产品型号可并存多份 BOM:不同厂家供货/含半成品等不同料单结构,工单建单时选定一份)
// BomItem 产品物料清单明细(一个产品型号可并存多份:不同厂家供货/含半成品等不同料单结构,工单建单时选定一份)
type BomItem struct {
ent.Schema
}
@@ -24,7 +24,7 @@ func (BomItem) Fields() []ent.Field {
return []ent.Field{
field.Int("id").Positive(),
field.String("productCode").Default("").MaxLen(64).Comment("产品编码"),
field.String("bomName").Default("默认").MaxLen(64).Comment("BOM名称:同一型号多份并存(如 厂家A/厂家B/含半成品),工单按名选用"),
field.String("bomName").Default("默认").MaxLen(64).Comment("物料清单名称:同一型号多份并存(如 厂家A/厂家B/含半成品),工单按名选用"),
field.String("materialCode").MaxLen(64).Comment("物料编码"),
field.String("materialName").Default("").MaxLen(128),
field.String("spec").Default("").MaxLen(128),
+36 -1
View File
@@ -127,7 +127,34 @@ async function refreshTasks() {
function onSelectionChange(sel) { selected.value = sel }
function rowKey(row) { return row.requestNo + '-' + row.materialCode + '-' + row.planDate }
onMounted(() => { loadDocks(); loadTasks() })
/* ===================== 取消任务 / 对接模式 ===================== */
const agvMode = reactive({ mock: false, tip: '' })
function canCancel(status) {
return status === 'PENDING' || status === 'DISPATCHED' || status === 'DELIVERING'
}
async function cancelTask(row) {
const { value: reason } = await ElMessageBox.prompt(
`确认取消 AGV 任务 ${row.taskNo}${row.sourceDock}${row.targetDock})?\n取消后关联的备料单行将退回待发料。`,
'取消 AGV 任务',
{ type: 'warning', confirmButtonText: '确认取消', cancelButtonText: '返回', inputPlaceholder: '取消原因(选填)', inputValue: '' }
).catch(() => ({ value: null }))
if (reason === null) return
await request.post('/agv/cancel', { taskNo: row.taskNo, reason: reason || '' })
ElMessage.success('已取消')
loadTasks(); loadDocks()
}
async function loadMode() {
try {
const m = await request.get('/agv/mode')
agvMode.mock = !!m?.mock
agvMode.tip = m?.tip || ''
} catch { /* 忽略 */ }
}
onMounted(() => { loadDocks(); loadTasks(); loadMode() })
</script>
<template>
@@ -154,6 +181,8 @@ onMounted(() => { loadDocks(); loadTasks() })
<!-- AGV 任务列表 -->
<el-tab-pane label="AGV任务" name="tasks" lazy>
<el-alert v-if="agvMode.mock" type="warning" :closable="false" show-icon style="margin-bottom:10px"
title="当前为模拟模式(未接真实海康 RCS)" :description="agvMode.tip" />
<div class="toolbar">
<el-select v-model="taskQuery.status" clearable placeholder="任务状态" style="width: 140px" @change="taskPage.current = 1; loadTasks()">
<el-option v-for="s in statusOptions" :key="s.v" :label="s.l" :value="s.v" />
@@ -193,6 +222,12 @@ onMounted(() => { loadDocks(); loadTasks() })
<el-table-column prop="operator" label="下发人" width="100" />
<el-table-column prop="hikTaskCode" label="RCS任务码" width="150" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button v-if="canCancel(row.status)" size="small" type="danger" plain @click="cancelTask(row)">取消</el-button>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<el-pagination background layout="total, sizes, prev, pager, next" :total="taskTotal"
:current-page="taskPage.current" :page-size="taskPage.size" :page-sizes="[10, 20, 50, 100]"
+59 -14
View File
@@ -1,6 +1,7 @@
// Package agv 海康 AGV 调度系统(RCS-2000 V4.2) 对接客户端。
// 能力:下发搬运任务(task/submit)、查询任务状态(task/query)。
// 能力:下发搬运任务(task/submit)、查询任务状态(task/query)、任务取消(task/cancel)
// 签名协议详见《AGV海康.md》:HMAC-SHA256 加盐 + MD5 二次摘要,sign 放 URL query。
// 完整请求路径 = 服务前缀 /rcs/rtas + 业务路径(文档 §2 开头)。
// 未配置真实 RCS(appKey/appSecret/BaseURL) 或 EnableMock=true 时走 Mock 客户端。
package agv
@@ -23,12 +24,19 @@ import (
"bj_power_wms/internal/config"
)
// ServicePrefix RCS-2000 统一服务前缀(文档 §2:「服务前缀统一:/rcs/rtas」)
const ServicePrefix = "/rcs/rtas"
// Client AGV 调度客户端抽象
type Client interface {
// SubmitTask 下发搬运任务 fromDock -> toDock,返回海康任务号 robotTaskCode。
SubmitTask(ctx context.Context, fromDock, toDock, carrierCode string) (string, error)
// QueryTask 查询任务状态,返回海康 taskStatus 枚举。
QueryTask(ctx context.Context, robotTaskCode string) (string, error)
// CancelTask 取消任务(软取消 CANCEL / 硬取消 DROP)。
CancelTask(ctx context.Context, robotTaskCode, cancelType, reason string) error
// Disable 是否处于 Mock(未接真实 RCS)模式。
Disable() bool
}
// New 依据配置返回真实或 Mock 客户端
@@ -42,9 +50,9 @@ func New(cfg config.AgvConfig) Client {
// ---------- 签名与请求(真实海康) ----------
type realClient struct {
cfg config.AgvConfig
hc *http.Client
carrier string
cfg config.AgvConfig
hc *http.Client
carrier string
}
func newRealClient(cfg config.AgvConfig) *realClient {
@@ -55,13 +63,17 @@ func newRealClient(cfg config.AgvConfig) *realClient {
}
}
// buildPath 拼接服务前缀 + 业务路径,system
// url 拼接 RCS 服务前缀 + 业务路径(文档 §2:完整请求路径 = 服务前缀 + 路径)。
// 兼容用户把 BaseURL 直接填成已含 /rcs/rtas 的情况,避免重复前缀。
func (c *realClient) url(path string) string {
base := strings.TrimRight(c.cfg.BaseURL, "/")
return base + path
if strings.HasSuffix(base, ServicePrefix) {
return base + path
}
return base + ServicePrefix + path
}
// submit 任务下发实现的实际请求
// post 任务下发实现的实际请求
// 海康路径:/rcs/rtas/api/robot/controller/task/submit
func (c *realClient) post(ctx context.Context, path string, payload map[string]any, out any) error {
body, err := json.Marshal(payload)
@@ -93,7 +105,7 @@ func (c *realClient) post(ctx context.Context, path string, payload map[string]a
if err := json.Unmarshal(raw, &envelope); err != nil {
return fmt.Errorf("agv parse envelope: %w", err)
}
if envelope.Code != "" && envelope.Code != "SUCCESS" {
if envelope.Code != "" && envelope.Code != "SUCCESS" && envelope.Code != "0" {
return fmt.Errorf("agv code=%s message=%s", envelope.Code, envelope.Message)
}
if out != nil && len(envelope.Data) > 0 {
@@ -146,28 +158,33 @@ func (c *realClient) sign(req *http.Request, body []byte, path string) {
req.Header.Set("Authorization", authHeader)
req.Header.Set("X-lr-appkey", c.cfg.AppKey)
// X-lr-request-id 是文档标注的唯一「必需」首部字段(1.1 节 是否必需=是)
req.Header.Set("X-lr-request-id", requestID)
req.Header.Set("X-lr-version", "v1.0")
req.Header.Set("X-lr-trace-id", traceID)
req.Header.Set("X-lr-source", "bj_power_wms")
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
}
// SubmitTask 实现 Client:下发搬运任务。
// 海康路径 /rcs/rtas/api/robot/controller/task/submit
func (c *realClient) SubmitTask(ctx context.Context, fromDock, toDock, carrierCode string) (string, error) {
carrier := carrierCode
if carrier == "" {
carrier = c.carrier
}
payload := map[string]any{
"taskType": "PF-LMR-COMMON",
"targetRoute": []map[string]any{
{"type": "SITE", "code": fromDock, "operation": "COLLECT"},
{"type": "SITE", "code": toDock, "operation": "DELIVERY"},
},
"carrierInfo": []map[string]any{
}
// 载具:给了就带;未给(无托盘搬运)不带 carrierInfo,避免 RCS 按空载具校验失败
carrier := carrierCode
if carrier == "" {
carrier = c.carrier
}
if carrier != "" {
payload["carrierInfo"] = []map[string]any{
{"carrierType": "1", "carrierCode": carrier},
},
}
}
var data struct {
RobotTaskCode string `json:"robotTaskCode"`
@@ -206,6 +223,28 @@ func (c *realClient) QueryTask(ctx context.Context, robotTaskCode string) (strin
return st, nil
}
// CancelTask 实现 Client:取消任务(文档 2.1.4 任务取消接口)。
// cancelTypeCANCEL 软取消(可下发回库)/ DROP 人工介入硬取消。
func (c *realClient) CancelTask(ctx context.Context, robotTaskCode, cancelType, reason string) error {
if robotTaskCode == "" {
return fmt.Errorf("缺少任务号")
}
if cancelType == "" {
cancelType = "CANCEL"
}
payload := map[string]any{
"robotTaskCode": robotTaskCode,
"cancelType": cancelType,
}
if reason != "" {
payload["reason"] = reason
}
return c.post(ctx, "/api/robot/controller/task/cancel", payload, nil)
}
// Disable 真实客户端非 Mock 模式
func (c *realClient) Disable() bool { return false }
// ---------- Mock 客户端(未接真实 RCS 时用) ----------
type mockClient struct{}
@@ -222,6 +261,12 @@ func (m *mockClient) QueryTask(ctx context.Context, robotTaskCode string) (strin
return "FINISHED", nil
}
func (m *mockClient) CancelTask(ctx context.Context, robotTaskCode, cancelType, reason string) error {
return nil
}
func (m *mockClient) Disable() bool { return true }
func randHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
+67
View File
@@ -200,6 +200,73 @@ func mapHikStatus(s string) string {
}
}
// cancelAgvHandler 取消 AGV 任务(软取消)。
// 已下发(DISPATCHED/DELIVERING)的调海康 RCS task/cancel;本地任务置 CANCELLED
// 并把关联的 MES 备料单行从 DELIVERING 退回待发料状态。
func cancelAgvHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
TaskNo string `json:"taskNo"`
Reason string `json:"reason"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
if req.TaskNo == "" {
fail(w, http.StatusBadRequest, "缺少任务号")
return
}
t, err := ctx.EntClient.AgvTask.Query().Where(agvtask.TaskNo(req.TaskNo)).First(ctx0())
if err != nil {
fail(w, http.StatusNotFound, "AGV 任务不存在")
return
}
if t.Status == "DONE" || t.Status == "CANCELLED" {
fail(w, http.StatusBadRequest, "任务已结束,无法取消")
return
}
operator := r.Header.Get("X-Username")
// 已下发的先通知 RCS 取消
if t.HikTaskCode != "" && (t.Status == "DISPATCHED" || t.Status == "DELIVERING") {
if cerr := ctx.Agv.CancelTask(ctx0(), t.HikTaskCode, "CANCEL", req.Reason); cerr != nil {
fail(w, http.StatusBadGateway, "RCS 取消失败: "+cerr.Error())
return
}
}
if err := ctx.EntClient.AgvTask.UpdateOneID(t.ID).
SetStatus("CANCELLED").SetRemark(req.Reason).Exec(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
// 关联备料单行退回待发料
mats := []agvSubmitRow{}
_ = json.Unmarshal([]byte(t.MaterialsJSON), &mats)
for _, row := range mats {
_ = ctx.Mes.MarkStatus(ctx0(), row.RequestNo, "WAIT_SEND")
}
ctx.EventLog.Write(ctx0(), "agv.cancel", operator, "agv_task", t.TaskNo,
"取消AGV搬运 "+t.SourceDock+"→"+t.TargetDock, map[string]any{"reason": req.Reason})
ok(w, nil)
}
}
// agvModeHandler 返回 AGV 对接模式(mock / 真实 RCS),供前端提示当前是否在模拟。
func agvModeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
mock := ctx.Agv.Disable()
ok(w, map[string]any{
"mock": mock,
"tip": func() string {
if mock {
return "当前为模拟模式(未接真实海康 RCS):任务会立即标记为已到位。现场对接请在配置中关闭 EnableMock 并填写 RCS 地址/appKey/appSecret。"
}
return "已对接真实海康 RCS。"
}(),
})
}
}
func listDocksHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
+2
View File
@@ -94,6 +94,8 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
{Method: http.MethodPost, Path: "/api/agv/refresh", Handler: requirePerm(ctx, "agv:manage", refreshAgvHandler(ctx))},
{Method: http.MethodGet, Path: "/api/agv/docks", Handler: requirePerm(ctx, "agv:manage", listDocksHandler(ctx))},
{Method: http.MethodGet, Path: "/api/agv/materials", Handler: requirePerm(ctx, "agv:manage", listAgvMaterialsHandler(ctx))},
{Method: http.MethodPost, Path: "/api/agv/cancel", Handler: requirePerm(ctx, "agv:manage", cancelAgvHandler(ctx))},
{Method: http.MethodGet, Path: "/api/agv/mode", Handler: requirePerm(ctx, "agv:manage", agvModeHandler(ctx))},
},
)
+1 -1
View File
@@ -6,7 +6,7 @@
<title>WMS库房管理</title>
<!-- 系统图标:库房箱体(内联 SVG,避免依赖外部文件) -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231668dc'/><path d='M16 7l9 4.5v9L16 25l-9-4.5v-9L16 7z' fill='none' stroke='%23fff' stroke-width='2' stroke-linejoin='round'/><path d='M7 11.5L16 16l9-4.5M16 16v9' fill='none' stroke='%23fff' stroke-width='2' stroke-linejoin='round'/></svg>">
<script type="module" crossorigin src="/assets/index-bU5Rgip-.js"></script>
<script type="module" crossorigin src="/assets/index-3rohbG0R.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ckb056lG.css">
</head>
<body>
@@ -93,11 +93,11 @@ export const qtyReport = (data) => request.post('/api/qty-report', data)
// 工位退料:{orderNo, stationNo, sn, materialCode, materialName, spec, qty, unit, reason}
export const returnMaterial = (data) => request.post('/api/return-material', data)
// ---------- 在制品 / 0-13 上下线建档完工 / BOM 领料 / 追溯(代理 MES 内部 API ----------
// ---------- 在制品 / 0-13 上下线建档完工 / 物料清单领料 / 追溯(代理 MES 内部 API ----------
// 在制品列表:{stationNo}<=0/空=全部, 0=上线位, 13=下线位, 其余物理工位)→ {list}
export const getWip = (params) => request.get('/api/wip', { params })
// BOM 物料清单(上线建档「领料确认」应领料):{productCode, bomName}
// 物料清单(上线建档「领料确认」应领料):{productCode, bomName}
export const getBom = (params) => request.get('/api/bom', { params })
// 工单查询(选工单带出产品编码/名称):{orderNo}
+4 -4
View File
@@ -84,7 +84,7 @@ export const helpPdf = {
export const helpOnline = {
title: '上线建档 使用说明',
overview:
'上线建档运行在产线进线口(工位号 0)。成品在此登记发号、按物料清单核对领料,建档上线后流转到第 1 道装配工位。\n\n标准流程:①选工单(带出产品与物料清单)→ ②按 BOM 核对应领料 → ③扫描/生成工件 SN → ④点「确认建档上线」。\n\n数据链路:工单/物料清单(MES) → 建档上线(MES 生成在制工件,工位号 0) → 流转到装配工位。',
'上线建档运行在产线进线口(工位号 0)。成品在此登记发号、按物料清单核对领料,建档上线后流转到第 1 道装配工位。\n\n标准流程:①选工单(带出产品与物料清单)→ ②按物料清单核对应领料 → ③扫描/生成工件 SN → ④点「确认建档上线」。\n\n数据链路:工单/物料清单(MES) → 建档上线(MES 生成在制工件,工位号 0) → 流转到装配工位。',
sections: [
{
title: '选工单与产品',
@@ -96,9 +96,9 @@ export const helpOnline = {
},
{
title: '领料确认(按物料清单核对)',
overview: '按产品 BOM 列出本台应领物料,操作员逐项核对后勾选确认。',
overview: '按产品物料清单列出本台应领物料,操作员逐项核对后勾选确认。',
fields: [
{ name: '应领料清单', source: 'MES 物料清单(BOM)。按产品编码查询。', purpose: '列明单台用量、图号、规格、管理方式、装配工位,指导备料', fill: '对照实物逐项核对,确认无误后勾选「我已核对」' },
{ name: '应领料清单', source: 'MES 物料清单。按产品编码查询。', purpose: '列明单台用量、图号、规格、管理方式、装配工位,指导备料', fill: '对照实物逐项核对,确认无误后勾选「我已核对」' },
{ name: '已核对勾选', source: '操作员确认。', purpose: '未勾选则无法提交建档,防止漏料上线', fill: '核对完成后勾选;有物料清单时必须勾选' }
]
},
@@ -151,7 +151,7 @@ export const helpMaterial = {
title: '装配物料绑定',
overview: '先在「作业」页扫描工件 SN,再按本工位应绑物料逐项扫码绑定。',
fields: [
{ name: '应绑物料', source: 'MES 物料清单(BOM)。按产品与工位带出。', purpose: '列明本工位需绑定的物料及已绑/应绑数量', fill: '对照标签:电气件扫 SN、结构件扫批次号,回车或点「绑定」' },
{ name: '应绑物料', source: 'MES 物料清单。按产品与工位带出。', purpose: '列明本工位需绑定的物料及已绑/应绑数量', fill: '对照标签:电气件扫 SN、结构件扫批次号,回车或点「绑定」' },
{ name: '已绑标签', source: 'MES 绑定记录。', purpose: '展示已绑定的 SN/批次,可点 × 撤销', fill: '绑错时点标签上的 × 撤销重绑' },
{ name: '已绑齐/未绑齐', source: '系统统计。已绑数 / 应绑数。', purpose: '提示本工位物料是否齐套', fill: '未绑齐会红色提示缺料,需补齐后才能完工' }
]
@@ -48,7 +48,7 @@
</div>
<el-empty v-if="!form.orderNo" description="请先选择工单" :image-size="70" />
<el-empty v-else-if="!bomItems.length" description="该产品未配置物料清单BOM" :image-size="70" />
<el-empty v-else-if="!bomItems.length" description="该产品未配置物料清单" :image-size="70" />
<template v-else>
<el-table :data="bomItems" border size="large" max-height="360">
<el-table-column type="index" label="#" width="55" align="center" />
@@ -162,7 +162,7 @@ async function loadOrders() {
loadingOrder.value = false
}
// 选工单 → 带出产品编码/名称 → 查 BOM 应领料
// 选工单 → 带出产品编码/名称 → 查物料清单应领料
async function onOrderChange(orderNo) {
product.code = ''
product.name = ''
+1 -1
View File
@@ -6,7 +6,7 @@
<title>工位终端</title>
<!-- 系统图标:工位终端屏(内联 SVG,避免依赖外部文件) -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231668dc'/><rect x='6' y='7' width='20' height='14' rx='2' fill='none' stroke='%23fff' stroke-width='2'/><path d='M12 25h8M16 21v4' stroke='%23fff' stroke-width='2' stroke-linecap='round'/></svg>">
<script type="module" crossorigin src="/assets/index-Bed51ON8.js"></script>
<script type="module" crossorigin src="/assets/index-CETtbp9K.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dz4RGjfJ.css">
</head>
<body>