Files
bj_power/bj_power_wms/internal/handler/agv.go
T
SunYF bf3a408839 refactor: 统一术语BOM为物料清单并补充工位扩展能力
主要变更:
1.  后端:重构工位管理逻辑,新增工位自定义扩展能力,将工位号上限调整为999并支持14+虚拟工位
2.  前后端:全量替换"BOM"术语为"物料清单",包括提示语、注释和文档
3.  WMS模块:新增AGV任务取消接口与前端页面,对接海康RCS取消任务逻辑
4.  权限与种子数据:新增工位新增权限并更新权限配置
5.  修复错误校验逻辑,优化AGV客户端签名与请求处理
2026-09-17 19:41:00 +08:00

363 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"encoding/json"
"net/http"
"strings"
"time"
"bj_power_wms/ent"
"bj_power_wms/ent/agvtask"
"bj_power_wms/ent/dock"
"bj_power_wms/internal/svc"
"github.com/google/uuid"
)
// ---------- AGV 配送接口 ----------
// submitAgvRequest 仓管下发 AGV:至少选一批待发料行,按 target_dock 聚合生成任务
type submitAgvRequest struct {
// rows: 待发料行(每行含 requestNo/物料/数量/targetDock),来自 MES 备料单
Rows []agvSubmitRow `json:"rows"`
// 也可以直接给一个 groupsourceDock + 多目标
SourceDock string `json:"sourceDock"`
}
type agvSubmitRow struct {
RequestNo string `json:"requestNo"`
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Unit string `json:"unit"`
Qty float64 `json:"qty"`
TargetDock string `json:"targetDock"`
}
func submitAgvHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req submitAgvRequest
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
if len(req.Rows) == 0 {
fail(w, http.StatusBadRequest, "请至少选择一批待发料")
return
}
source := req.SourceDock
if source == "" {
source = "DOCK21"
}
operator := r.Header.Get("X-Username")
// 按 target_dock 聚合
groups := map[string][]agvSubmitRow{}
for _, row := range req.Rows {
if row.TargetDock == "" {
continue
}
groups[row.TargetDock] = append(groups[row.TargetDock], row)
}
if len(groups) == 0 {
fail(w, http.StatusBadRequest, "所选料行缺少目标接驳台(targetDock)")
return
}
created := 0
failed := []string{}
for target, rows := range groups {
// 1. 生成我方任务号并落库(PENDING→下发)
taskNo := "AGV" + strings.ToUpper(uuid.NewString()[:10])
mats, _ := json.Marshal(rows)
t, err := ctx.EntClient.AgvTask.Create().
SetTaskNo(taskNo).
SetSourceDock(source).
SetTargetDock(target).
SetMaterialsJSON(string(mats)).
SetStatus("PENDING").
SetOperator(operator).
SetDispatchedAt(0).
SetArrivedAt(0).
Save(ctx0())
if err != nil {
failed = append(failed, target)
continue
}
// 2. 调海康 RCS 下发
robCode, aerr := ctx.Agv.SubmitTask(ctx0(), source, target, "")
if aerr != nil {
ctx.EntClient.AgvTask.UpdateOneID(t.ID).
SetStatus("FAILED").SetRemark("RCS下发失败: "+aerr.Error()).
Exec(ctx0())
failed = append(failed, target)
continue
}
ctx.EntClient.AgvTask.UpdateOneID(t.ID).
SetHikTaskCode(robCode).
SetStatus("DISPATCHED").
SetDispatchedAt(time.Now().Unix()).
Exec(ctx0())
// 3. 回写 MES 备料单 → DELIVERING
for _, row := range rows {
_ = ctx.Mes.MarkStatus(ctx0(), row.RequestNo, "DELIVERING")
}
// 4. 记录事件日志
ctx.EventLog.Write(ctx0(), "agv.dispatch", operator, "agv_task", taskNo,
"下发AGV搬运 "+source+"→"+target, map[string]any{"rows": len(rows)})
created++
}
ok(w, map[string]any{"created": created, "failed": failed})
}
}
func listAgvTasksHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
query := ctx.EntClient.AgvTask.Query()
if st := q.Get("status"); st != "" {
query = query.Where(agvtask.Status(st))
}
if dock2 := q.Get("targetDock"); dock2 != "" {
query = query.Where(agvtask.TargetDock(dock2))
}
page := atoi(q.Get("page"), 1)
size := atoi(q.Get("pageSize"), 20)
if size < 1 || size > 200 {
size = 20
}
total, err := query.Clone().Count(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
list, err := query.
Order(ent.Desc("created_at")).
Offset((page - 1) * size).Limit(size).
All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, map[string]any{"list": list, "total": total, "page": page, "pageSize": size})
}
}
// refreshAgvHandler 轮询海康 RCS 更新各任务状态
func refreshAgvHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 取所有未终态(DISPATCHED/DELIVERING)且已下发的任务
list, err := ctx.EntClient.AgvTask.Query().
Where(
agvtask.StatusIn("DISPATCHED", "DELIVERING"),
agvtask.HikTaskCodeNEQ(""),
).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
changed := 0
for _, t := range list {
st, qerr := ctx.Agv.QueryTask(ctx0(), t.HikTaskCode)
if qerr != nil {
continue
}
// 海康状态映射到我们的状态
our := mapHikStatus(st)
if our == "DONE" {
ctx.EntClient.AgvTask.UpdateOneID(t.ID).
SetStatus("DONE").SetArrivedAt(time.Now().Unix()).
Exec(ctx0())
// 到位后回写 MES 备料单 → DONE
mats := []agvSubmitRow{}
_ = json.Unmarshal([]byte(t.MaterialsJSON), &mats)
for _, row := range mats {
_ = ctx.Mes.MarkStatus(ctx0(), row.RequestNo, "DONE")
}
ctx.EventLog.Write(ctx0(), "agv.arrive", r.Header.Get("X-Username"), "agv_task", t.TaskNo,
"AGV已到位 "+t.TargetDock, nil)
changed++
} else if our != "" && t.Status != our && our == "DELIVERING" {
ctx.EntClient.AgvTask.UpdateOneID(t.ID).SetStatus("DELIVERING").Exec(ctx0())
changed++
}
}
ok(w, map[string]any{"changed": changed})
}
}
// mapHikStatus 海康 taskStatus → 我方状态
// 海康: QUEUE/WAIT/EXECUTING/MANUALED/FINISHED/CANCELLED
func mapHikStatus(s string) string {
switch s {
case "EXECUTING", "DELIVERING":
return "DELIVERING"
case "FINISHED", "MANUALED":
return "DONE"
case "CANCELLED":
return "CANCELLED"
default: // QUEUE / WAIT / 未知
return "DISPATCHED"
}
}
// 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()
query := ctx.EntClient.Dock.Query()
if typ := q.Get("dockType"); typ != "" {
query = query.Where(dock.DockType(typ))
}
list, err := query.Order(ent.Asc("id")).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
// 显式构造,避免 ent omitempty 吞掉 hasPallet=false 导致前端看不到空闲状态
type dockView struct {
ID int64 `json:"id"`
DockCode string `json:"dockCode"`
Name string `json:"name"`
DockType string `json:"dockType"`
StationNo int `json:"stationNo"`
Status string `json:"status"`
HasPallet bool `json:"hasPallet"`
PalletRef string `json:"palletRef"`
}
views := make([]dockView, 0, len(list))
for _, d := range list {
views = append(views, dockView{
ID: int64(d.ID),
DockCode: d.DockCode,
Name: d.Name,
DockType: d.DockType,
StationNo: d.StationNo,
Status: d.Status,
HasPallet: d.HasPallet,
PalletRef: d.PalletRef,
})
}
ok(w, map[string]any{"list": views})
}
}
// listAgvMaterialsHandler 仓管下发 AGV 时拉取 MES 备料单(含 target_dock),本地分页。
// MES 内部接口全量返回备料单行;备料单行数可控,内存分页足够。
func listAgvMaterialsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
status := q.Get("status")
all, err := ctx.Mes.ListMaterialRequests(ctx0(), status)
if err != nil {
fail(w, http.StatusInternalServerError, "拉取备料单失败: "+err.Error())
return
}
page := atoi(q.Get("page"), 1)
size := atoi(q.Get("pageSize"), 20)
if size < 1 || size > 200 {
size = 20
}
total := len(all)
start := (page - 1) * size
if start > total {
start = total
}
end := start + size
if end > total {
end = total
}
ok(w, map[string]any{"list": all[start:end], "total": total, "page": page, "pageSize": size})
}
}
// syncDockPalletHandler 内部接口:MES 同步某接驳台是否有托盘(PLC 托盘传感器)。
func syncDockPalletHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return wrapInternal(ctx)(func(w http.ResponseWriter, r *http.Request) {
var req struct {
DockCode string `json:"dockCode"`
HasPallet bool `json:"hasPallet"`
PalletRef string `json:"palletRef"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
upd := ctx.EntClient.Dock.Update().
Where(dock.DockCode(req.DockCode)).
SetHasPallet(req.HasPallet)
if req.PalletRef != "" {
upd = upd.SetPalletRef(req.PalletRef)
}
if err := upd.Exec(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, nil)
})
}