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
+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>