feat: 完成产线与仓储系统多模块迭代升级

本次迭代覆盖MES与WMS核心业务:
1. 新增接驳台托盘传感器读取与AGV对接能力
2. 完善工单排产、备料流程与权限体系拆分
3. 优化看板接口与前端路由、样式
4. 新增操作日志、库存盘点与角色保护逻辑
5. 修复代理地址、BOM保存等已知问题
This commit is contained in:
SunYF
2026-09-04 14:18:53 +08:00
parent c1fc6d32b2
commit b6799c9de4
138 changed files with 20613 additions and 2220 deletions
+296
View File
@@ -0,0 +1,296 @@
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"
}
}
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)
})
}
+28
View File
@@ -0,0 +1,28 @@
package handler
import (
"net/http"
"strconv"
"bj_power_wms/internal/svc"
)
// eventLogListHandler GET /api/event/logs 操作日志分页查询
// 筛选:eventType(事件类型) operator(操作人) entityType(对象类型) from/to(unix 秒)
func eventLogListHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return requirePerm(ctx, "eventlog:manage", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
pageSize, _ := strconv.Atoi(q.Get("pageSize"))
from, _ := strconv.ParseInt(q.Get("from"), 10, 64)
to, _ := strconv.ParseInt(q.Get("to"), 10, 64)
list, total, err := ctx.EventLog.Query(ctx0(),
q.Get("eventType"), q.Get("operator"), q.Get("entityType"),
from, to, page, pageSize)
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, map[string]any{"list": list, "total": total})
})
}
+4
View File
@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"strconv"
"time"
"bj_power_wms/ent"
@@ -204,6 +205,9 @@ func createInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
committed = true
ctx.EventLog.Write(ctx0(), "inbound.create", r.Header.Get("X-Username"), "inbound", inboundNo,
"入库 "+req.MaterialCode+" 数量 "+strconv.Itoa(totalQty), map[string]any{
"inboundType": req.InboundType, "quantity": totalQty, "snCount": len(req.SnList)})
logx.Infof("inbound: no=%s material=%s qty=%d by=%s", inboundNo, req.MaterialCode, totalQty, req.Operator)
ok(w, map[string]any{
"inboundNo": inboundNo,
@@ -119,6 +119,9 @@ func createInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
committed = true
ctx.EventLog.Write(ctx0(), "inspection.create", r.Header.Get("X-Username"), "inspection", req.TargetId,
"质量检验 "+req.MaterialCode+" → "+req.Status, map[string]any{
"targetType": req.TargetType, "inspectQty": req.InspectQty, "passQty": req.PassQty})
logx.Infof("inspection: %s %s -> %s by %s", req.TargetType, req.TargetId, req.Status, req.Inspector)
ok(w, rec)
}
+57
View File
@@ -70,6 +70,8 @@ func createMaterialHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, "创建物料失败: "+err.Error())
return
}
ctx.EventLog.Write(ctx0(), "material.create", r.Header.Get("X-Username"), "material", req.Code,
"新增物料 "+req.Code+" "+req.Name, map[string]any{"manageMode": manageMode})
ok(w, m)
}
@@ -91,6 +93,11 @@ func updateMaterialHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusBadRequest, "参数错误")
return
}
cur, gerr := ctx.EntClient.Material.Get(ctx0(), req.ID)
if gerr != nil {
fail(w, http.StatusNotFound, "物料不存在")
return
}
upd := ctx.EntClient.Material.UpdateOneID(req.ID)
if req.Name != "" {
@@ -111,6 +118,8 @@ func updateMaterialHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, "更新物料失败: "+err.Error())
return
}
ctx.EventLog.Write(ctx0(), "material.update", r.Header.Get("X-Username"), "material", cur.Code,
"编辑物料 "+cur.Code+" "+cur.Name, nil)
ok(w, m)
}
}
@@ -213,6 +222,52 @@ func getMaterialHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
}
// materialExistsHandler 内部接口:批量校验物料编码是否存在于物料档案。
// body: {"codes":["A","B"]} → data: {"missing":["B"]}(不存在于档案的编码列表)。
// 供 MES 备料单生成前防呆使用(wrapInternal + X-API-TOKEN 保护)。
func materialExistsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Codes []string `json:"codes"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
// 去重并过滤空串
seen := map[string]bool{}
codes := []string{}
for _, c := range req.Codes {
c = strings.TrimSpace(c)
if c == "" || seen[c] {
continue
}
seen[c] = true
codes = append(codes, c)
}
missing := []string{}
if len(codes) > 0 {
// 查存在的编码集合
exist, err := ctx.EntClient.Material.Query().
Where(material.CodeIn(codes...)).
Select(material.FieldCode).
All(ctx0())
if err == nil {
have := map[string]bool{}
for _, m := range exist {
have[m.Code] = true
}
for _, c := range codes {
if !have[c] {
missing = append(missing, c)
}
}
}
}
ok(w, map[string]any{"missing": missing})
}
}
// exportMaterialsHandler 物料档案全量导出(xlsx),按当前筛选条件导出
func exportMaterialsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -404,6 +459,8 @@ func excelMaterialImportHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return
}
committed = true
ctx.EventLog.Write(ctx0(), "material.import", r.Header.Get("X-Username"), "material", "",
"Excel 导入物料", map[string]any{"success": len(plans)})
ok(w, map[string]any{"success": len(plans), "failed": 0, "errors": []importErr{}})
}
}
+24 -1
View File
@@ -75,6 +75,8 @@ func semiInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return
}
ctx.EventLog.Write(ctx0(), "semi.inbound", r.Header.Get("X-Username"), "semi", req.Sn,
"半成品入库 "+req.Sn, map[string]any{"materialCode": materialCode})
ok(w, rec)
}
}
@@ -112,6 +114,8 @@ func semiOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
SetStatus("出库").
ExecX(ctx0())
ctx.EventLog.Write(ctx0(), "semi.outbound", r.Header.Get("X-Username"), "semi", req.Sn,
"半成品出库 "+req.Sn, nil)
ok(w, map[string]any{"sn": req.Sn, "status": "出库"})
}
}
@@ -174,7 +178,26 @@ func queryLedgerHandler(ctx *svc.ServiceContext) http.HandlerFunc {
q = q.Where(ordermaterialledger.MaterialNameContains(materialName))
}
if status != "" {
q = q.Where(ordermaterialledger.StatusEQ(status))
// 状态枚举:待领料 / 部分领料 由「进行中 + 累计出库数」派生(表内只存 进行中/领料完结)
// 待领料 = 进行中 且 已出库=0;部分领料 = 进行中 且 已出库>0
switch status {
case "待领料":
q = q.Where(ordermaterialledger.And(
ordermaterialledger.StatusEQ("进行中"),
ordermaterialledger.OutQtyEQ(0),
))
case "部分领料":
q = q.Where(ordermaterialledger.And(
ordermaterialledger.StatusEQ("进行中"),
ordermaterialledger.OutQtyGT(0),
))
case "领料完结":
q = q.Where(ordermaterialledger.StatusEQ("领料完结"))
case "进行中": // 兼容旧调用:看全部未完结
q = q.Where(ordermaterialledger.StatusEQ("进行中"))
default:
q = q.Where(ordermaterialledger.StatusEQ(status))
}
}
total, err := q.Count(ctx0())
if err != nil {
@@ -219,6 +219,9 @@ func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
committed = true
ctx.EventLog.Write(ctx0(), "outbound.create", r.Header.Get("X-Username"), "outbound", outboundNo,
"备料出库 工单 "+req.OrderNo+" 物料 "+req.MaterialCode+" ×"+itoa(actualQty),
map[string]any{"orderNo": req.OrderNo, "qty": actualQty})
logx.Infof("deduct stock: order=%s material=%s qty=%d", req.OrderNo, req.MaterialCode, actualQty)
ok(w, map[string]any{
"outboundNo": outboundNo,
@@ -447,6 +450,9 @@ func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
committed = true
ctx.EventLog.Write(ctx0(), "outbound.general", r.Header.Get("X-Username"), "outbound", outboundNo,
"通用出库 "+req.MaterialCode+" ×"+itoa(actualQty),
map[string]any{"qty": actualQty, "boxNo": req.BoxNo, "snCount": len(req.SnList)})
logx.Infof("general outbound: material=%s qty=%d box=%s", req.MaterialCode, actualQty, req.BoxNo)
ok(w, map[string]any{
"outboundNo": outboundNo,
+27 -6
View File
@@ -198,9 +198,14 @@ func createUserHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
role := roleOrDefault(req.Role)
// role 字符串与 role_id 用同一角色语义:优先 role_id(数据化),否则按字符串回退。
// 若用户给的是 role_code,尝试从 roles 表反查 role_id。
// 显式给 role_id 时回填 role 字符串为该角色实际 code(前端 UserManage 只传 roleId,避免 role 落成 operator);
// 只给 role_code 时反查 roles 表补 role_id。
roleID := req.RoleID
if roleID == 0 {
if roleID > 0 {
if rl, err := ctx.EntClient.Role.Get(ctx0(), roleID); err == nil {
role = rl.Code
}
} else if role != "" {
if rl, err := ctx.EntClient.Role.Query().Where(userrole.CodeEQ(role)).Only(ctx0()); err == nil {
roleID = rl.ID
}
@@ -217,6 +222,8 @@ func createUserHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ctx.EventLog.Write(ctx0(), "user.create", r.Header.Get("X-Username"), "user", strconv.Itoa(u.ID),
"新建账号 "+u.Username, map[string]any{"roleId": roleID, "dept": req.Dept})
ok(w, userDTO(u))
})
}
@@ -251,11 +258,17 @@ func updateUserHandler(ctx *svc.ServiceContext) http.HandlerFunc {
if req.RealName != "" {
upd.SetRealName(req.RealName)
}
if req.Role != "" {
upd.SetRole(roleOrDefault(req.Role))
}
if req.RoleID > 0 {
upd.SetRoleID(req.RoleID)
// 显式给 role_id:同步回填 role 字符串为该角色实际 code,避免双字段不一致
if rl, err := ctx.EntClient.Role.Get(ctx0(), req.RoleID); err == nil {
upd.SetRole(rl.Code).SetRoleID(rl.ID)
}
} else if req.Role != "" {
role := roleOrDefault(req.Role)
upd.SetRole(role)
if rl, err := ctx.EntClient.Role.Query().Where(userrole.CodeEQ(role)).Only(ctx0()); err == nil {
upd.SetRoleID(rl.ID)
}
}
if req.Dept != "" {
upd.SetNillableDept(strPtr(req.Dept))
@@ -281,6 +294,12 @@ func updateUserHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ctx.EventLog.Write(ctx0(), "user.update", r.Header.Get("X-Username"), "user", strconv.Itoa(u.ID),
"编辑账号 "+target.Username, map[string]any{
"resetPassword": req.Password != "",
"isActive": req.IsActive != nil,
"changedRole": req.Role != "" || req.RoleID > 0,
})
ok(w, userDTO(u))
})
}
@@ -322,6 +341,8 @@ func deleteUserHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ctx.EventLog.Write(ctx0(), "user.delete", r.Header.Get("X-Username"), "user", strconv.Itoa(req.ID),
"删除账号 "+target.Username, map[string]any{"role": target.Role})
ok(w, nil)
})
}
@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"strconv"
"strings"
"bj_power_wms/ent"
@@ -39,6 +40,8 @@ var seedPermissions = []seedPerm{
{"material:view", "物料档案", "MENU", "/material", "Goods", 100, ""},
{"user:manage", "账号管理", "MENU", "/users", "EditPen", 110, ""},
{"role:manage", "角色管理", "MENU", "/roles", "Key", 120, ""},
{"eventlog:manage", "操作日志", "MENU", "/event-log", "Clock", 130, ""},
{"agv:manage", "AGV配送", "MENU", "/agv", "Van", 135, ""},
// ===== 按钮权限 =====
{"inbound:create", "批量入库", "BUTTON", "", "", 200, "inbound:view"},
{"inbound:import", "Excel导入", "BUTTON", "", "", 201, "inbound:view"},
@@ -64,6 +67,7 @@ var seedPermissions = []seedPerm{
{"role:create", "新增角色", "BUTTON", "", "", 270, "role:manage"},
{"role:edit", "编辑角色", "BUTTON", "", "", 271, "role:manage"},
{"role:delete", "删除角色", "BUTTON", "", "", 272, "role:manage"},
{"agv:dispatch", "下发AGV", "BUTTON", "", "", 280, "agv:manage"},
// 注:导出权限已按菜单拆分(inbound/outbound/inspection/inventory:export),
// 不再使用全局 *:export,以更精细地控制“谁能在哪个菜单导出”。
}
@@ -196,6 +200,11 @@ func seedRbacHandler(ctx *svc.ServiceContext) http.HandlerFunc {
// 清理已废弃的全局 *:export 权限行(迁移后无角色引用,幂等)
_, _ = ctx.EntClient.Permission.Delete().Where(permission.CodeEQ("*:export")).Exec(ctx0())
// 幂等 seed 只在发生真实变更(新增权限/角色/绑定历史用户)时记录日志,避免每次页面加载刷屏
if createdP > 0 || createdR > 0 || bound > 0 {
ctx.EventLog.Write(ctx0(), "rbac.seed", r.Header.Get("X-Username"), "rbac", "",
"RBAC 种子初始化", map[string]any{"permissions": createdP, "roles": createdR, "bound": bound})
}
logx.Infof("rbac seed: 新增权限 %d 条、角色 %d 个、绑定历史用户 %d 个", createdP, createdR, bound)
ok(w, map[string]any{"createdPermissions": createdP, "createdRoles": createdR, "boundUsers": bound})
})
@@ -213,6 +222,25 @@ func listRolesHandler(ctx *svc.ServiceContext) http.HandlerFunc {
})
}
// roleOptionsHandler GET /api/roles/options 角色轻量选项(供账号管理页下拉选择角色)
// 与角色管理页的 /api/roles 解耦:仅需 user:manage 即可读,不要求 role:manage
func roleOptionsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return requirePerm(ctx, "user:manage", func(w http.ResponseWriter, r *http.Request) {
list, err := ctx.EntClient.Role.Query().Order(ent.Asc("id")).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]map[string]any, 0, len(list))
for _, rl := range list {
out = append(out, map[string]any{
"id": rl.ID, "code": rl.Code, "name": rl.Name,
})
}
ok(w, map[string]any{"list": out})
})
}
// createRoleHandler POST /api/roles 新增角色
func createRoleHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return requirePerm(ctx, "role:create", func(w http.ResponseWriter, r *http.Request) {
@@ -243,6 +271,8 @@ func createRoleHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ctx.EventLog.Write(ctx0(), "role.create", r.Header.Get("X-Username"), "role", strconv.Itoa(rl.ID),
"新增角色 "+req.Name+"("+req.Code+")", map[string]any{"count": len(codes)})
ok(w, rl)
})
}
@@ -286,6 +316,8 @@ func updateRoleHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ctx.EventLog.Write(ctx0(), "role.update", r.Header.Get("X-Username"), "role", strconv.Itoa(req.ID),
"编辑角色 "+target.Name+"("+target.Code+")", map[string]any{"permissionCodes": rl.PermissionCodes})
ok(w, rl)
})
}
@@ -313,6 +345,8 @@ func deleteRoleHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ctx.EventLog.Write(ctx0(), "role.delete", r.Header.Get("X-Username"), "role", strconv.Itoa(req.ID),
"删除角色 "+target.Name+"("+target.Code+")", nil)
ok(w, nil)
})
}
+16
View File
@@ -35,10 +35,12 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
[]rest.Route{
{Method: http.MethodPost, Path: "/api/rbac/seed", Handler: seedRbacHandler(ctx)},
{Method: http.MethodGet, Path: "/api/roles", Handler: listRolesHandler(ctx)},
{Method: http.MethodGet, Path: "/api/roles/options", Handler: roleOptionsHandler(ctx)},
{Method: http.MethodPost, Path: "/api/roles", Handler: createRoleHandler(ctx)},
{Method: http.MethodPost, Path: "/api/roles/update", Handler: updateRoleHandler(ctx)},
{Method: http.MethodPost, Path: "/api/roles/delete", Handler: deleteRoleHandler(ctx)},
{Method: http.MethodGet, Path: "/api/permissions", Handler: listPermissionsHandler(ctx)},
{Method: http.MethodGet, Path: "/api/event/logs", Handler: eventLogListHandler(ctx)},
},
)
@@ -76,10 +78,22 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
},
)
// AGV 配送(海康 RCS 对接:下发/任务列表/状态轮询/接驳台状态)
server.AddRoutes(
[]rest.Route{
{Method: http.MethodPost, Path: "/api/agv/submit", Handler: requirePerm(ctx, "agv:manage", submitAgvHandler(ctx))},
{Method: http.MethodGet, Path: "/api/agv/tasks", Handler: requirePerm(ctx, "agv:manage", listAgvTasksHandler(ctx))},
{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))},
},
)
// 库存
server.AddRoutes(
[]rest.Route{
{Method: http.MethodGet, Path: "/api/stock/query", Handler: queryStockHandler(ctx)},
{Method: http.MethodGet, Path: "/api/stock/material-summary", Handler: materialSummaryHandler(ctx)},
{Method: http.MethodGet, Path: "/api/stock/details", Handler: stockDetailsHandler(ctx)},
{Method: http.MethodGet, Path: "/api/stock/export", Handler: exportStockHandler(ctx)},
{Method: http.MethodGet, Path: "/api/stock/zone-summary", Handler: zoneSummaryHandler(ctx)},
@@ -156,6 +170,8 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
{Method: http.MethodGet, Path: "/api/internal/stock/query", Handler: wrapInternal(ctx)(queryStockHandler(ctx))},
{Method: http.MethodPost, Path: "/api/internal/semi/inbound", Handler: wrapInternal(ctx)(semiInboundHandler(ctx))},
{Method: http.MethodPost, Path: "/api/internal/semi/outbound", Handler: wrapInternal(ctx)(semiOutboundHandler(ctx))},
{Method: http.MethodPost, Path: "/api/internal/material/exists", Handler: wrapInternal(ctx)(materialExistsHandler(ctx))},
{Method: http.MethodPost, Path: "/api/internal/dock/pallet-sync", Handler: syncDockPalletHandler(ctx)},
},
)
}
+228
View File
@@ -254,6 +254,207 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR
return rows, total, nil
}
// materialAggRow 物料汇总行(按 物料编码 聚合,忽略区域与质量状态)
// 回答"这个物料总共有多少":总数/锁定/可用 + 质量分布(合格/未检/不合格) + 分布区域列表。
type materialAggRow struct {
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Spec string `json:"spec"`
ManageMode int `json:"manageMode"` // 1结构件/2精密件
TotalQty int `json:"totalQty"` // 总数量(结构件=数量求和;精密件=SN 行数)
LockedQty int `json:"lockedQty"` // 锁定量
AvailQty int `json:"availQty"` // 可用量 = total - locked
QtyQualified int `json:"qtyQualified"` // 合格数量
QtyPending int `json:"qtyPending"` // 未检数量
QtyRejected int `json:"qtyRejected"` // 不合格数量
Zones []string `json:"zones"` // 分布区域列表
ZoneCount int `json:"zoneCount"` // 分布区域数
BatchCount int `json:"batchCount"` // 批次数(结构件维度的行数)
SnCount int `json:"snCount"` // SN 数(精密件维度的行数)
LastInboundNo string `json:"lastInboundNo"` // 最近入库单号
CreatedAt int64 `json:"createdAt"` // 最近一次入库时间
}
// materialSummaryArgs 物料汇总的筛选条件
type materialSummaryArgs struct {
MaterialCode string
MaterialName string
ManageMode string
Page int
PageSize int
}
// materialSummaryHandler 物料汇总(按物料聚合所有区域/质量状态)
// GET /api/stock/material-summary?materialCode=&materialName=&manageMode=&page=&pageSize=
// 返回 rows: 一物料一行,含 总数/可用/锁定/合格/未检/不合格/分布区域。明细经 /stock/details 点击下钻。
// 与主列表不同:汇总忽略区域/质量/入库时间筛选(回答总量问题,只认物料与类型)。
func materialSummaryHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, total, err := aggregateMaterialSummary(ctx, materialSummaryArgs{
MaterialCode: r.URL.Query().Get("materialCode"),
MaterialName: r.URL.Query().Get("materialName"),
ManageMode: r.URL.Query().Get("manageMode"),
Page: atoi(r.URL.Query().Get("page"), 1),
PageSize: atoi(r.URL.Query().Get("pageSize"), 20),
})
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, map[string]any{"rows": rows, "total": total})
}
}
// aggregateMaterialSummary 按物料编码聚合全部在库库存(忽略区域/质量/时间维度),Go 侧内存聚合 + 分页。
func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([]*materialAggRow, int, error) {
q := ctx.EntClient.Inventory.Query()
q = q.Where(inventory.StatusIn("在库", "锁定"))
q = q.Where(inventory.Or(inventory.ManageModeEQ(2), inventory.QuantityGT(0)))
if a.MaterialCode != "" {
q = q.Where(inventory.MaterialCodeContains(a.MaterialCode))
}
if a.MaterialName != "" {
q = q.Where(inventory.MaterialNameContains(a.MaterialName))
}
if a.ManageMode == "1" {
q = q.Where(inventory.ManageModeEQ(1))
} else if a.ManageMode == "2" {
q = q.Where(inventory.ManageModeEQ(2))
}
all, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0())
if err != nil {
return nil, 0, err
}
// 物料名称/规格补全
matName := map[string]string{}
matSpec := map[string]string{}
matCodes := []string{}
seen := map[string]bool{}
for _, inv := range all {
if !seen[inv.MaterialCode] {
seen[inv.MaterialCode] = true
matCodes = append(matCodes, inv.MaterialCode)
}
}
if len(matCodes) > 0 {
mats, _ := ctx.EntClient.Material.Query().
Where(material.CodeIn(matCodes...)).All(ctx0())
for _, m := range mats {
matName[m.Code] = m.Name
matSpec[m.Code] = m.Spec
}
}
type agg struct {
totalQty int
lockedQty int
qQualified int
qPending int
qRejected int
mMode int // 管理粒度(同物料唯一,取首见行的值)
batchCnt int
snCnt int
zones []string
zoneSeen map[string]bool
lastInbNo string
lastTime int64
}
group := map[string]*agg{} // key: materialCode
order := []string{}
for _, inv := range all {
key := inv.MaterialCode
g, ok := group[key]
if !ok {
g = &agg{mMode: inv.ManageMode, zoneSeen: map[string]bool{}}
group[key] = g
order = append(order, key)
}
// 数量
if inv.ManageMode == 1 {
g.totalQty += inv.Quantity
g.lockedQty += inv.LockedQty
g.batchCnt++
} else {
g.totalQty++
if inv.Status == "锁定" {
g.lockedQty++
}
g.snCnt++
}
// 质量分布(未检=空或非合格/不合格)
switch inv.QualityStatus {
case "合格":
g.qQualified += qtyOf(inv)
case "不合格":
g.qRejected += qtyOf(inv)
default:
g.qPending += qtyOf(inv)
}
// 分布区域
zc := inv.ZoneCode
if zc == "" {
zc = "未分区"
}
if !g.zoneSeen[zc] {
g.zoneSeen[zc] = true
g.zones = append(g.zones, zc)
}
if inv.CreatedAt > g.lastTime {
g.lastTime = inv.CreatedAt
g.lastInbNo = inv.InboundNo
}
}
total := len(order)
start := (a.Page - 1) * a.PageSize
if start < 0 {
start = 0
}
end := start + a.PageSize
if end > total {
end = total
}
rows := make([]*materialAggRow, 0, end-start)
for i := start; i < end; i++ {
code := order[i]
g := group[code]
name := matName[code]
if name == "" {
name = code
}
rows = append(rows, &materialAggRow{
MaterialCode: code,
MaterialName: name,
Spec: matSpec[code],
ManageMode: g.mMode,
TotalQty: g.totalQty,
LockedQty: g.lockedQty,
AvailQty: g.totalQty - g.lockedQty,
QtyQualified: g.qQualified,
QtyPending: g.qPending,
QtyRejected: g.qRejected,
Zones: g.zones,
ZoneCount: len(g.zones),
BatchCount: g.batchCnt,
SnCount: g.snCnt,
LastInboundNo: g.lastInbNo,
CreatedAt: g.lastTime,
})
}
return rows, total, nil
}
// qtyOf 库存行计入数量的口径:结构件按行数量、精密件按 1
func qtyOf(inv *ent.Inventory) int {
if inv.ManageMode == 1 {
return inv.Quantity
}
return 1
}
// stockDetailsHandler 库存明细(点击聚合行下钻,分页懒加载)
// GET /api/stock/details?materialCode=&zoneCode=&qualityStatus=&manageMode=&page=&pageSize=
// 返回该物料/区域/质量/类型下的原始批次/SN 明细行(扁平展示,含 SN/批次号)。
@@ -307,9 +508,36 @@ func stockDetailsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
// exportStockHandler 库存汇总全量导出(xlsx)
// 与 /stock/query 同一套聚合逻辑,但导出当前筛选下的全部行(不分页)。
// 支持 ?view=material 导出「物料汇总」(/stock/material-summary 的全量版本)。
// 返回 .xlsx 二进制,文件名 = 库存汇总_时间.xlsx。
func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("view") == "material" {
rows, _, err := aggregateMaterialSummary(ctx, materialSummaryArgs{
MaterialCode: r.URL.Query().Get("materialCode"),
MaterialName: r.URL.Query().Get("materialName"),
ManageMode: r.URL.Query().Get("manageMode"),
Page: 1,
PageSize: 1000000, // 全量
})
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
headers := []string{"物料编码", "物料名称", "规格", "类型", "总数量", "锁定量", "可用量", "合格", "未检", "不合格", "分布区域", "批次数", "SN数", "最近入库单", "最近入库时间"}
matrix := make([][]any, 0, len(rows))
for _, rw := range rows {
matrix = append(matrix, []any{
rw.MaterialCode, rw.MaterialName, rw.Spec,
manageModeLabel(rw.ManageMode), rw.TotalQty, rw.LockedQty, rw.AvailQty,
rw.QtyQualified, rw.QtyPending, rw.QtyRejected,
strings.Join(rw.Zones, "、"), rw.BatchCount, rw.SnCount,
rw.LastInboundNo, unixFmt(rw.CreatedAt),
})
}
sendExcel(w, xlsxFilename("物料汇总"), headers, matrix)
return
}
rows, _, err := aggregateStock(ctx, aggregateStockArgs{
MaterialCode: r.URL.Query().Get("materialCode"),
MaterialName: r.URL.Query().Get("materialName"),
@@ -83,6 +83,9 @@ func startStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, "创建盘点单失败: "+err.Error())
return
}
ctx.EventLog.Write(ctx0(), "stocktake.start", r.Header.Get("X-Username"), "stocktake", rec.StocktakeNo,
"发起盘点 "+rec.StocktakeNo, map[string]any{"totalTargets": count, "zones": req.Zones,
"materialCodes": req.MaterialCodes, "manageMode": req.ManageMode})
ok(w, map[string]any{"stocktakeNo": rec.StocktakeNo, "totalTargets": count})
}
}
@@ -252,6 +255,9 @@ func finishStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
SetFinishedAt(time.Now().Unix()).
ExecX(ctx0())
ctx.EventLog.Write(ctx0(), "stocktake.finish", r.Header.Get("X-Username"), "stocktake", req.StocktakeNo,
"完成盘点 "+req.StocktakeNo, map[string]any{
"adjusted": req.Adjust, "counted": counted, "uncounted": uncounted, "diffs": diffs})
ok(w, map[string]any{
"stocktakeNo": req.StocktakeNo,
"adjusted": req.Adjust,
+5
View File
@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"strconv"
"time"
"bj_power_wms/ent"
@@ -48,6 +49,8 @@ func createZoneHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ctx.EventLog.Write(ctx0(), "zone.create", r.Header.Get("X-Username"), "zone", z.ZoneCode,
"新建区域 "+req.ZoneName, nil)
ok(w, z)
}
}
@@ -79,6 +82,8 @@ func updateZoneHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, "更新区域失败: "+err.Error())
return
}
ctx.EventLog.Write(ctx0(), "zone.update", r.Header.Get("X-Username"), "zone", req.ZoneCode,
"编辑区域 #"+strconv.Itoa(req.ID)+" "+req.ZoneName, nil)
ok(w, z)
}
}