refactor: 重构产线工位与备料流程,移除冗余字段
1. 移除工单、工件、日排产中的冗余工位组合/接驳台字段 2. 新增工位接驳台编码字段与唯一约束,关联WMS接驳台主数据 3. 重构日排产为工位产量分配模式,替代原接驳台列表 4. 新增备料单工位级字段与分批出库支持 5. 移除定时同步可生产数量,改为WMS实时拉取接口 6. 过滤操作日志,排除登录/退出相关日志 7. 调整仪表盘工序展示逻辑为今日派工路线 8. 新增接驳台维护菜单与基础数据
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bj_power_wms/ent"
|
||||
"bj_power_wms/ent/inventory"
|
||||
"bj_power_wms/ent/ordermaterialledger"
|
||||
"bj_power_wms/internal/svc"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
// backflushRequest 反冲扣账请求(MES 报工完成时调用,X-API-TOKEN 内部接口)
|
||||
// 物料在该道工序被消耗,不依赖事先发料:报工完成即反冲扣库存。
|
||||
// items 由 MES 按「工单产品 BOM + 当前工序 processCode」算好(含损耗),WMS 只负责 FIFO 扣减与记账。
|
||||
type backflushRequest struct {
|
||||
OrderNo string `json:"orderNo"` // 关联工单号(可空=不挂台账)
|
||||
StationNo int `json:"stationNo"` // 当前工位号(记录用)
|
||||
ProcessCode int `json:"processCode"` // 当前工序号(记录用)
|
||||
Operator string `json:"operator"`
|
||||
Items []backflushItem `json:"items"` // 本次消耗的物料明细
|
||||
}
|
||||
|
||||
type backflushItem struct {
|
||||
MaterialCode string `json:"materialCode"`
|
||||
Qty int `json:"qty"` // 该工序单件消耗(已含损耗)* 完成件数
|
||||
}
|
||||
|
||||
// backflushInternalHandler 反冲扣账(库存 FIFO 扣减 + 生成 backflush 类型出库单 + 台账累计)
|
||||
// 事务保证:出库单 + 库存扣减 + 出库明细 + 工单台账 原子提交。
|
||||
// 任意物料库存不足(FIFO 无法足额)时,扣足可用部分、记短欠日志、不阻塞产线(反冲降级同成品回流)。
|
||||
func backflushInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req backflushRequest
|
||||
if err := parseJSON(r, &req); err != nil {
|
||||
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.Items) == 0 {
|
||||
ok(w, map[string]any{"outboundNo": "", "consumed": 0, "shortfall": false, "note": "无反冲物料"})
|
||||
return
|
||||
}
|
||||
|
||||
outboundNo := "BF" + uuid.NewString()[:12]
|
||||
|
||||
tx, err := ctx.EntClient.Tx(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// 1. 创建反冲出库单(type=backflush,数量后置更新)
|
||||
ob, err := tx.OutboundOrder.Create().
|
||||
SetOutboundNo(outboundNo).
|
||||
SetOutboundType("backflush").
|
||||
SetNillableOrderNo(strPtr(req.OrderNo)).
|
||||
SetMaterialCode(""). // 反冲单为整份 BOM,主表物料留空,明细承载
|
||||
SetManageMode(1).
|
||||
SetQuantity(0).
|
||||
SetNillableOperator(strPtr(req.Operator)).
|
||||
SetNillableTargetStation(strPtr(strconv.Itoa(req.StationNo))).
|
||||
SetRemark("反冲扣账(工序" + strconv.Itoa(req.ProcessCode) + ")").
|
||||
Save(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, "创建反冲单失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 2. FIFO 逐物料扣减 + 台账累计
|
||||
totalConsumed := 0
|
||||
shortfall := false
|
||||
for _, it := range req.Items {
|
||||
if it.MaterialCode == "" || it.Qty <= 0 {
|
||||
continue
|
||||
}
|
||||
consumed, sf := fifoConsume(tx, it.MaterialCode, it.Qty, outboundNo, req.OrderNo)
|
||||
totalConsumed += consumed
|
||||
if sf {
|
||||
shortfall = true
|
||||
logx.Errorf("backflush shortfall: order=%s material=%s need=%d got=%d", req.OrderNo, it.MaterialCode, it.Qty, consumed)
|
||||
}
|
||||
// 台账累计(仅挂工单时)
|
||||
if req.OrderNo != "" {
|
||||
ledger, lerr := tx.OrderMaterialLedger.Query().
|
||||
Where(
|
||||
ordermaterialledger.OrderNoEQ(req.OrderNo),
|
||||
ordermaterialledger.MaterialCodeEQ(it.MaterialCode),
|
||||
).Only(ctx0())
|
||||
if lerr == nil && ledger != nil {
|
||||
newOut := ledger.OutQty + consumed
|
||||
status := ledger.Status
|
||||
if newOut >= ledger.TotalQty {
|
||||
status = "领料完结"
|
||||
}
|
||||
upd := tx.OrderMaterialLedger.UpdateOneID(ledger.ID).
|
||||
SetOutQty(newOut).
|
||||
SetStatus(status)
|
||||
if status == "领料完结" && ledger.Status != "领料完结" {
|
||||
upd.SetCompletedAt(time.Now().Unix())
|
||||
}
|
||||
if _, lerr = upd.Save(ctx0()); lerr != nil {
|
||||
fail(w, http.StatusInternalServerError, "更新台账失败: "+lerr.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 回写出库单实际数量
|
||||
if _, err = tx.OutboundOrder.UpdateOneID(ob.ID).SetQuantity(totalConsumed).Save(ctx0()); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "更新反冲单失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
|
||||
ctx.EventLog.Write(ctx0(), "material.backflush", req.Operator, "backflush", outboundNo,
|
||||
"反冲扣账 工单 "+req.OrderNo+" 工序"+strconv.Itoa(req.ProcessCode)+" 共消耗 ×"+strconv.Itoa(totalConsumed),
|
||||
map[string]any{"orderNo": req.OrderNo, "stationNo": req.StationNo, "consumed": totalConsumed, "shortfall": shortfall})
|
||||
logx.Infof("backflush: order=%s station=%d consumed=%d shortfall=%v", req.OrderNo, req.StationNo, totalConsumed, shortfall)
|
||||
ok(w, map[string]any{
|
||||
"outboundNo": outboundNo,
|
||||
"consumed": totalConsumed,
|
||||
"shortfall": shortfall,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fifoConsume 按 created_at 升序(先进先出)扣减某物料库存,跨批次/单件 SN 统一处理。
|
||||
// 返回实际扣减数;库存不足足额时 shortfall=true(已扣可用部分),不报错。
|
||||
func fifoConsume(tx *ent.Tx, materialCode string, qty int, outboundNo, orderNo string) (int, bool) {
|
||||
rows, err := tx.Inventory.Query().
|
||||
Where(
|
||||
inventory.MaterialCodeEQ(materialCode),
|
||||
inventory.StatusEQ("在库"),
|
||||
inventory.QualityStatusNotIn("不合格", "待退"),
|
||||
).
|
||||
Order(ent.Asc("created_at")).
|
||||
All(ctx0())
|
||||
if err != nil {
|
||||
return 0, true
|
||||
}
|
||||
remaining := qty
|
||||
consumed := 0
|
||||
for _, row := range rows {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
avail := row.Quantity - row.LockedQty
|
||||
if avail <= 0 {
|
||||
continue
|
||||
}
|
||||
take := avail
|
||||
if take > remaining {
|
||||
take = remaining
|
||||
}
|
||||
if row.ManageMode == 2 {
|
||||
// SN 单件:整件消耗并置出库
|
||||
aff, e := tx.Inventory.Update().
|
||||
Where(inventory.ID(row.ID), inventory.StatusEQ("在库")).
|
||||
SetStatus("出库").Save(ctx0())
|
||||
if e != nil || aff == 0 {
|
||||
continue
|
||||
}
|
||||
if _, e = tx.OutboundDetail.Create().
|
||||
SetOutboundNo(outboundNo).
|
||||
SetSnCode(row.SnCode).
|
||||
SetMaterialCode(materialCode).
|
||||
SetQuantity(1).
|
||||
SetNillableOrderNo(strPtr(orderNo)).Save(ctx0()); e != nil {
|
||||
return consumed, true
|
||||
}
|
||||
consumed += 1
|
||||
remaining -= 1
|
||||
} else {
|
||||
// 批次:条件原子扣减(quantity>=take 才减,避免超扣/并发负库存)
|
||||
aff, e := tx.Inventory.Update().
|
||||
Where(inventory.ID(row.ID), inventory.QuantityGTE(take)).
|
||||
AddQuantity(-take).
|
||||
AddLockedQty(-min(take, row.LockedQty)).
|
||||
Save(ctx0())
|
||||
if e != nil || aff == 0 {
|
||||
continue
|
||||
}
|
||||
if _, e = tx.OutboundDetail.Create().
|
||||
SetOutboundNo(outboundNo).
|
||||
SetBatchNo(row.BatchNo).
|
||||
SetMaterialCode(materialCode).
|
||||
SetQuantity(take).
|
||||
SetNillableOrderNo(strPtr(orderNo)).Save(ctx0()); e != nil {
|
||||
return consumed, true
|
||||
}
|
||||
consumed += take
|
||||
remaining -= take
|
||||
}
|
||||
}
|
||||
return consumed, remaining > 0
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"bj_power_wms/ent"
|
||||
"bj_power_wms/ent/dock"
|
||||
"bj_power_wms/internal/svc"
|
||||
)
|
||||
|
||||
// dockRow 接驳台返回 DTO(显式 json 标签,避免依赖 ent 默认编组)。
|
||||
type dockRow struct {
|
||||
DockCode string `json:"dockCode"`
|
||||
Name string `json:"name"`
|
||||
DockType string `json:"dockType"`
|
||||
StationNo int `json:"stationNo"`
|
||||
HasPallet bool `json:"hasPallet"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// listDocksInternalHandler 返回接驳台主数据列表(dock 真源在 WMS)。
|
||||
// MES 工位维护页拉取此接口填充「送达接驳台」下拉,避免写死 DOCK01..20 留后患。
|
||||
func listDocksInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := ctx.EntClient.Dock.Query().
|
||||
Order(ent.Asc("dock_code")).All(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, "查询接驳台失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
rows := make([]dockRow, 0, len(list))
|
||||
for _, d := range list {
|
||||
rows = append(rows, dockRow{
|
||||
DockCode: d.DockCode,
|
||||
Name: d.Name,
|
||||
DockType: d.DockType,
|
||||
StationNo: d.StationNo,
|
||||
HasPallet: d.HasPallet,
|
||||
Status: d.Status,
|
||||
})
|
||||
}
|
||||
ok(w, map[string]any{"list": rows})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 接驳台主数据维护 ----------
|
||||
// 接驳台是独立主数据:除产线工位旁,库房、缓存区等其他位置也有接驳台位。
|
||||
// 类型:line=产线(绑定工位,与工位 1:1)/ store=库房 / other=其他(均不绑定工位)。
|
||||
|
||||
// dockMaintRow 接驳台维护页返回行(显式 json,避免 ent omitempty 吞掉 hasPallet=false)
|
||||
type dockMaintRow 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"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
func dockMaintRowOf(d *ent.Dock) dockMaintRow {
|
||||
return dockMaintRow{
|
||||
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,
|
||||
CreatedAt: d.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// listDocksMaintHandler GET /api/docks?dockType=&keyword=&page=&pageSize=
|
||||
// 接驳台主数据分页查询(真分页在 SQL 层),默认按创建时间倒序。
|
||||
func listDocksMaintHandler(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))
|
||||
}
|
||||
if kw := strings.TrimSpace(q.Get("keyword")); kw != "" {
|
||||
query = query.Where(dock.Or(
|
||||
dock.DockCodeContainsFold(kw),
|
||||
dock.NameContainsFold(kw),
|
||||
))
|
||||
}
|
||||
total, err := query.Count(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, "查询接驳台失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
page := atoi(q.Get("page"), 1)
|
||||
size := atoi(q.Get("pageSize"), 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 200 {
|
||||
size = 20
|
||||
}
|
||||
list, err := query.
|
||||
Order(ent.Desc("created_at"), ent.Desc("id")).
|
||||
Offset((page - 1) * size).Limit(size).All(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, "查询接驳台失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
rows := make([]dockMaintRow, 0, len(list))
|
||||
for _, d := range list {
|
||||
rows = append(rows, dockMaintRowOf(d))
|
||||
}
|
||||
ok(w, map[string]any{"list": rows, "total": total, "page": page, "pageSize": size})
|
||||
}
|
||||
}
|
||||
|
||||
// dockReq 接驳台保存请求
|
||||
type dockReq 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"`
|
||||
}
|
||||
|
||||
// validateDock 校验接驳台字段:
|
||||
// - 编码/名称必填,编码全库唯一;
|
||||
// - 类型仅 line/store/other;
|
||||
// - 产线接驳台必须绑定工位号(>0)且与工位 1:1;库房/其他接驳台不绑定工位(固定 0)。
|
||||
func validateDock(ctx *svc.ServiceContext, req *dockReq, selfID int64) error {
|
||||
req.DockCode = strings.TrimSpace(req.DockCode)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.DockCode == "" {
|
||||
return errors.New("接驳台编码必填")
|
||||
}
|
||||
if req.Name == "" {
|
||||
return errors.New("接驳台名称必填")
|
||||
}
|
||||
switch req.DockType {
|
||||
case "line", "store", "other":
|
||||
default:
|
||||
return errors.New("接驳台类型仅支持 产线/库房/其他")
|
||||
}
|
||||
if req.Status != "ENABLED" && req.Status != "DISABLED" {
|
||||
return errors.New("接驳台状态仅支持 启用/停用")
|
||||
}
|
||||
if req.DockType != "line" {
|
||||
req.StationNo = 0
|
||||
} else if req.StationNo <= 0 {
|
||||
return errors.New("产线接驳台必须绑定工位号")
|
||||
}
|
||||
dupCode := ctx.EntClient.Dock.Query().Where(dock.DockCode(req.DockCode))
|
||||
if selfID > 0 {
|
||||
dupCode = dupCode.Where(dock.IDNEQ(int(selfID)))
|
||||
}
|
||||
if exists, err := dupCode.Exist(ctx0()); err == nil && exists {
|
||||
return errors.New("接驳台编码已存在:" + req.DockCode)
|
||||
}
|
||||
if req.StationNo > 0 {
|
||||
q := ctx.EntClient.Dock.Query().Where(dock.StationNo(req.StationNo))
|
||||
if selfID > 0 {
|
||||
q = q.Where(dock.IDNEQ(int(selfID)))
|
||||
}
|
||||
other, err := q.First(ctx0())
|
||||
if err == nil && other != nil {
|
||||
return errors.New("该工位已绑定接驳台 " + other.DockCode + ",一个工位只能有一个接驳台")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDockHandler POST /api/docks 新增接驳台
|
||||
func createDockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req dockReq
|
||||
if err := parseJSON(r, &req); err != nil {
|
||||
fail(w, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
if req.DockType == "" {
|
||||
req.DockType = "line"
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = "ENABLED"
|
||||
}
|
||||
if err := validateDock(ctx, &req, 0); err != nil {
|
||||
fail(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_, err := ctx.EntClient.Dock.Create().
|
||||
SetDockCode(req.DockCode).SetName(req.Name).SetDockType(req.DockType).
|
||||
SetStationNo(req.StationNo).SetHasPallet(false).SetStatus(req.Status).
|
||||
Save(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, "新增接驳台失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
ctx.EventLog.Write(ctx0(), "dock.create", r.Header.Get("X-Username"), "dock", req.DockCode,
|
||||
"新增接驳台", map[string]any{"dockCode": req.DockCode, "dockType": req.DockType, "stationNo": req.StationNo})
|
||||
ok(w, map[string]any{"message": "接驳台已新增"})
|
||||
}
|
||||
}
|
||||
|
||||
// updateDockHandler POST /api/docks/update 修改接驳台(编码不可改,是产线/AGV 任务的引用锚点)
|
||||
func updateDockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req dockReq
|
||||
if err := parseJSON(r, &req); err != nil {
|
||||
fail(w, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
fail(w, http.StatusBadRequest, "缺少 id")
|
||||
return
|
||||
}
|
||||
old, err := ctx.EntClient.Dock.Get(ctx0(), int(req.ID))
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, "接驳台不存在")
|
||||
return
|
||||
}
|
||||
req.DockCode = old.DockCode
|
||||
if err := validateDock(ctx, &req, req.ID); err != nil {
|
||||
fail(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_, err = old.Update().
|
||||
SetName(req.Name).SetDockType(req.DockType).
|
||||
SetStationNo(req.StationNo).SetStatus(req.Status).Save(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, "修改接驳台失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
ctx.EventLog.Write(ctx0(), "dock.update", r.Header.Get("X-Username"), "dock", old.DockCode,
|
||||
"修改接驳台", map[string]any{"dockCode": old.DockCode, "dockType": req.DockType, "stationNo": req.StationNo})
|
||||
ok(w, map[string]any{"message": "接驳台已保存"})
|
||||
}
|
||||
}
|
||||
|
||||
// deleteDockHandler POST /api/docks/delete 删除接驳台
|
||||
// 正在占用(有托盘)的接驳台不允许删除,避免 AGV/产线任务指向已删除的接驳台。
|
||||
func deleteDockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := parseJSON(r, &req); err != nil {
|
||||
fail(w, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
fail(w, http.StatusBadRequest, "缺少 id")
|
||||
return
|
||||
}
|
||||
d, err := ctx.EntClient.Dock.Get(ctx0(), int(req.ID))
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, "接驳台不存在")
|
||||
return
|
||||
}
|
||||
if d.HasPallet {
|
||||
fail(w, http.StatusBadRequest, "接驳台 "+d.DockCode+" 当前有托盘占用,请先处理托盘后再删除")
|
||||
return
|
||||
}
|
||||
if err := ctx.EntClient.Dock.DeleteOneID(d.ID).Exec(ctx0()); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "删除接驳台失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
ctx.EventLog.Write(ctx0(), "dock.delete", r.Header.Get("X-Username"), "dock", d.DockCode,
|
||||
"删除接驳台", map[string]any{"dockCode": d.DockCode})
|
||||
ok(w, map[string]any{"message": "接驳台已删除"})
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,46 @@ func getMaterialHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// materialTypesHandler 内部接口:批量查询物料品类(item_type)。
|
||||
// body: {"codes":["A","B"]} → data: {"types":{"A":1,"B":3}}(1原材料/2半成品/3成品/4其他)。
|
||||
// 供 MES 递归展开产品物料清单使用:成品子项(item_type=3)需继续展开到原材料。
|
||||
func materialTypesHandler(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)
|
||||
}
|
||||
types := map[string]int{}
|
||||
if len(codes) > 0 {
|
||||
list, err := ctx.EntClient.Material.Query().
|
||||
Where(material.CodeIn(codes...)).
|
||||
Select(material.FieldCode, material.FieldItemType).
|
||||
All(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, "查询物料品类失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
for _, m := range list {
|
||||
types[m.Code] = m.ItemType
|
||||
}
|
||||
}
|
||||
ok(w, map[string]any{"types": types})
|
||||
}
|
||||
}
|
||||
|
||||
// materialExistsHandler 内部接口:批量校验物料编码是否存在于物料档案。
|
||||
// body: {"codes":["A","B"]} → data: {"missing":["B"]}(不存在于档案的编码列表)。
|
||||
// 供 MES 备料单生成前防呆使用(wrapInternal + X-API-TOKEN 保护)。
|
||||
|
||||
@@ -7,35 +7,27 @@ import (
|
||||
"bj_power_wms/internal/svc"
|
||||
)
|
||||
|
||||
// producibleSyncHandler 接收 MES 推送的可生产数量快照 + 未来5天物料需求(X-API-TOKEN 内部接口)
|
||||
//
|
||||
// 业务背景(问题记录 L470-474 + L256):
|
||||
// - 可生产套数 = min(各物料可用量 ÷ 单台用量),BOM 只在 MES、库存只在 WMS,
|
||||
// 由 MES 计算后推送快照,WMS 库存页卡片只读展示(两系统独立运行约束,WMS 不反向依赖 MES)。
|
||||
// 可生产数量(问题记录 L470-474 + L256):
|
||||
// - 可生产套数 = min(各物料可用量 ÷ 单台用量)。BOM 单台用量在 MES、库存可用量在 WMS,
|
||||
// 由 MES 实时计算,WMS 打开本页时按需拉一次(两系统之间不做定时/批量同步)。
|
||||
// - 未来5天需求量 = 近5天日排产 × 物料清单单台用量,是备料四态「预警」判定的数据源。
|
||||
//
|
||||
// 推送语义:全量覆盖(先清后写),单事务提交;MES 侧每次重算后整体推送一次。
|
||||
func producibleSyncHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
// 拉取语义:每次查询先实时取 MES 结果,再全量覆盖本地快照(单事务),保证展示与 MES 当前口径一致。
|
||||
|
||||
// queryProducibleHandler GET /api/stock/producible 可生产数量(实时拉取 MES 后落快照再返回)
|
||||
func queryProducibleHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Items []struct {
|
||||
ProductCode string `json:"productCode"`
|
||||
ProductName string `json:"productName"`
|
||||
ProducibleQty int `json:"producibleQty"`
|
||||
ShortText string `json:"shortText"` // 短板物料描述,如 "SCR-M4-12缺20; XX缺5"
|
||||
ComputedAt int64 `json:"computedAt"`
|
||||
} `json:"items"`
|
||||
Demands []struct {
|
||||
MaterialCode string `json:"materialCode"`
|
||||
Future5Qty int `json:"future5Qty"`
|
||||
} `json:"demands"`
|
||||
}
|
||||
if err := parseJSON(r, &req); err != nil {
|
||||
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
if ctx.Mes == nil {
|
||||
fail(w, http.StatusBadRequest, "未配置 MES 地址,无法获取可生产数量")
|
||||
return
|
||||
}
|
||||
res, err := ctx.Mes.FetchProducible(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadGateway, "拉取 MES 可生产数量失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
tx, err := ctx.EntClient.Tx(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
|
||||
@@ -48,12 +40,11 @@ func producibleSyncHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}()
|
||||
|
||||
// 可生产快照:全量覆盖
|
||||
if _, err = tx.ProducibleSnapshot.Delete().Exec(ctx0()); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "清理旧快照失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
for _, it := range req.Items {
|
||||
for _, it := range res.Items {
|
||||
if it.ProductCode == "" {
|
||||
continue
|
||||
}
|
||||
@@ -70,24 +61,21 @@ func producibleSyncHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// 未来5天需求:全量覆盖(demands 缺省时不动,允许只推快照不推需求)
|
||||
if req.Demands != nil {
|
||||
if _, err = tx.MaterialDemand.Delete().Exec(ctx0()); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "清理旧需求失败: "+err.Error())
|
||||
return
|
||||
if _, err = tx.MaterialDemand.Delete().Exec(ctx0()); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "清理旧需求失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
for _, d := range res.Demands {
|
||||
if d.MaterialCode == "" {
|
||||
continue
|
||||
}
|
||||
for _, d := range req.Demands {
|
||||
if d.MaterialCode == "" {
|
||||
continue
|
||||
}
|
||||
if _, err = tx.MaterialDemand.Create().
|
||||
SetMaterialCode(d.MaterialCode).
|
||||
SetFuture5Qty(d.Future5Qty).
|
||||
SetUpdatedAt(now).
|
||||
Save(ctx0()); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "写入需求失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if _, err = tx.MaterialDemand.Create().
|
||||
SetMaterialCode(d.MaterialCode).
|
||||
SetFuture5Qty(d.Future5Qty).
|
||||
SetUpdatedAt(now).
|
||||
Save(ctx0()); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "写入需求失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,25 +84,12 @@ func producibleSyncHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
ok(w, map[string]any{"items": len(req.Items), "demands": len(req.Demands)})
|
||||
}
|
||||
}
|
||||
|
||||
// queryProducibleHandler 可生产数量快照查询(WMS 库存页卡片数据源)
|
||||
// 返回 MES 最近推送的快照列表 + 同步时间;从未推送过则返回空列表,前端提示"暂无数据(等待 MES 同步)"。
|
||||
func queryProducibleHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := ctx.EntClient.ProducibleSnapshot.Query().All(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
syncedAt := int64(0)
|
||||
for _, s := range list {
|
||||
if s.SyncedAt > syncedAt {
|
||||
syncedAt = s.SyncedAt
|
||||
}
|
||||
}
|
||||
ok(w, map[string]any{"list": list, "syncedAt": syncedAt})
|
||||
ok(w, map[string]any{"list": list, "syncedAt": now})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ var seedPermissions = []seedPerm{
|
||||
{"role:manage", "角色管理", "MENU", "/roles", "Key", 120, ""},
|
||||
{"eventlog:manage", "操作日志", "MENU", "/event-log", "Clock", 130, ""},
|
||||
{"agv:manage", "AGV配送", "MENU", "/agv", "Van", 135, ""},
|
||||
{"dock:manage", "接驳台维护", "MENU", "/dock", "Aim", 136, ""},
|
||||
// ===== 按钮权限 =====
|
||||
{"inbound:create", "批量入库", "BUTTON", "", "", 200, "inbound:view"},
|
||||
{"inbound:import", "Excel导入", "BUTTON", "", "", 201, "inbound:view"},
|
||||
@@ -70,6 +71,9 @@ var seedPermissions = []seedPerm{
|
||||
{"role:delete", "删除角色", "BUTTON", "", "", 272, "role:manage"},
|
||||
{"agv:dispatch", "下发AGV", "BUTTON", "", "", 280, "agv:manage"},
|
||||
{"return:confirm", "确认收货", "BUTTON", "", "", 205, "return:view"},
|
||||
{"dock:add", "新增接驳台", "BUTTON", "", "", 290, "dock:manage"},
|
||||
{"dock:edit", "编辑接驳台", "BUTTON", "", "", 291, "dock:manage"},
|
||||
{"dock:delete", "删除接驳台", "BUTTON", "", "", 292, "dock:manage"},
|
||||
// 注:导出权限已按菜单拆分(inbound/outbound/inspection/inventory:export),
|
||||
// 不再使用全局 *:export,以更精细地控制“谁能在哪个菜单导出”。
|
||||
}
|
||||
|
||||
@@ -128,6 +128,16 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
|
||||
},
|
||||
)
|
||||
|
||||
// 接驳台主数据(独立主数据:产线/库房/其他,产线类型与工位 1:1 绑定)
|
||||
server.AddRoutes(
|
||||
[]rest.Route{
|
||||
{Method: http.MethodGet, Path: "/api/docks", Handler: requirePerm(ctx, "dock:manage", listDocksMaintHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/docks", Handler: requirePerm(ctx, "dock:add", createDockHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/docks/update", Handler: requirePerm(ctx, "dock:edit", updateDockHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/docks/delete", Handler: requirePerm(ctx, "dock:delete", deleteDockHandler(ctx))},
|
||||
},
|
||||
)
|
||||
|
||||
// 区域
|
||||
server.AddRoutes(
|
||||
[]rest.Route{
|
||||
@@ -210,9 +220,11 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
|
||||
{Method: http.MethodPut, Path: "/api/internal/product-types", Handler: wrapInternal(ctx)(updateProductTypeInternalHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/internal/product-types/delete", Handler: wrapInternal(ctx)(deleteProductTypeInternalHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/internal/material/exists", Handler: wrapInternal(ctx)(materialExistsHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/internal/producible/sync", Handler: wrapInternal(ctx)(producibleSyncHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/internal/material/types", Handler: wrapInternal(ctx)(materialTypesHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/internal/dock/pallet-sync", Handler: syncDockPalletHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/internal/return-order", Handler: wrapInternal(ctx)(createReturnOrderInternalHandler(ctx))},
|
||||
{Method: http.MethodGet, Path: "/api/internal/docks", Handler: wrapInternal(ctx)(listDocksInternalHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/internal/material/backflush", Handler: wrapInternal(ctx)(backflushInternalHandler(ctx))},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -370,6 +370,10 @@ func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([
|
||||
q = q.Where(inventory.CategoryEQ(invCatSemi))
|
||||
} else if a.Category == "3" {
|
||||
q = q.Where(inventory.CategoryEQ(invCatFinished))
|
||||
} else {
|
||||
// 半成品是自产中间品,不计入库存体系(不参与常规库存汇总/缺货预警统计),
|
||||
// 其流转在「半成品管理」单独记录;未显式指定品类时,汇总口径一律排除半成品。
|
||||
q = q.Where(inventory.CategoryNEQ(invCatSemi))
|
||||
}
|
||||
|
||||
all, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0())
|
||||
@@ -687,8 +691,9 @@ func buildZoneSummary(ctx *svc.ServiceContext) ([]zoneSummRow, error) {
|
||||
zoneCreatedAtMap[z.ZoneCode] = z.CreatedAt
|
||||
}
|
||||
|
||||
// 区域汇总属常规库存汇总口径,排除半成品(半成品单独记录,不并入库存体系)
|
||||
rows, err := ctx.EntClient.Inventory.Query().
|
||||
Where(inventory.StatusIn("在库", "锁定")).All(ctx0())
|
||||
Where(inventory.StatusIn("在库", "锁定"), inventory.CategoryNEQ(invCatSemi)).All(ctx0())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1026,8 +1031,9 @@ type shortageRow struct {
|
||||
// 可用量口径与出库门禁一致:在库/锁定 且质量状态非 不合格/待退。
|
||||
func shortageHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// 缺货预警只统计纳入库存体系的品类:半成品为自产中间品、不参与常规库存统计,故排除。
|
||||
mats, err := ctx.EntClient.Material.Query().
|
||||
Where(material.SafetyStockGT(0), material.IsActiveEQ(true)).
|
||||
Where(material.SafetyStockGT(0), material.IsActiveEQ(true), material.ItemTypeNEQ(2)).
|
||||
All(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
|
||||
Reference in New Issue
Block a user