feat: 完成产线与仓储系统多模块迭代升级
本次迭代覆盖MES与WMS核心业务: 1. 新增接驳台托盘传感器读取与AGV对接能力 2. 完善工单排产、备料流程与权限体系拆分 3. 优化看板接口与前端路由、样式 4. 新增操作日志、库存盘点与角色保护逻辑 5. 修复代理地址、BOM保存等已知问题
This commit is contained in:
@@ -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"`
|
||||
// 也可以直接给一个 group:sourceDock + 多目标
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user