refactor: 重构产线工位与备料流程,移除冗余字段

1. 移除工单、工件、日排产中的冗余工位组合/接驳台字段
2. 新增工位接驳台编码字段与唯一约束,关联WMS接驳台主数据
3. 重构日排产为工位产量分配模式,替代原接驳台列表
4. 新增备料单工位级字段与分批出库支持
5. 移除定时同步可生产数量,改为WMS实时拉取接口
6. 过滤操作日志,排除登录/退出相关日志
7. 调整仪表盘工序展示逻辑为今日派工路线
8. 新增接驳台维护菜单与基础数据
This commit is contained in:
SunYF
2026-09-19 07:50:21 +08:00
parent 543655d441
commit 37f10d3a13
156 changed files with 4489 additions and 1285 deletions
+212
View File
@@ -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
}
+277
View File
@@ -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": "接驳台已删除"})
}
}
+40
View File
@@ -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 保护)。
+31 -56
View File
@@ -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,以更精细地控制“谁能在哪个菜单导出”。
}
+13 -1
View File
@@ -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))},
},
)
}
+8 -2
View File
@@ -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())