feat: 新增装机绑定功能与权限、BOM工序配置

1.  新增装机绑定实体与相关CRUD逻辑,支持工件工序物料绑定/解绑
2.  为BOM物料新增装配工序字段,支持按工序校验绑定
3.  扩展权限表,新增父权限码字段支持菜单按钮关联
4.  统一各页面帮助弹窗参数,新增角色管理帮助文档
5.  新增公共格式化工具函数,优化侧边栏菜单展开逻辑
6.  新增工位终端绑定代理接口与MES内部绑定API
7.  修复主入口数据库连接与schema初始化逻辑,新增一键迁移工具
This commit is contained in:
SunYF
2026-09-07 11:58:19 +08:00
parent 92f0439d89
commit 6ee131a4bd
70 changed files with 6966 additions and 365 deletions
+22 -1
View File
@@ -87,12 +87,33 @@ func EnsureDB(c DatabaseConf) error {
return nil
}
// schemaPatchSQL 幂等列补丁:ent Schema.Create 只建缺失的新表,绝不会给既有表加列;
// 因此对"已有表新增列"的场景必须走显式 ALTER。这里统一维护补丁清单,migrate/seed/reset-all 三入口共用。
var schemaPatchSQL = []string{
// BOM 料增加"装配工序"维度(0=不参与工位绑定校验;1~12=在第几道工序装配)
// 注意:BomItem schema 表名注解为 work_order_bom(非默认名)
`ALTER TABLE work_order_bom ADD COLUMN IF NOT EXISTS process_code integer NOT NULL DEFAULT 0`,
}
// EnsureSchema ent 建表 + 幂等列补丁(只增,不删数据)
func EnsureSchema(client *ent.Client, sqlDB *sql.DB) error {
if err := client.Schema.Create(context.Background()); err != nil {
return err
}
for _, q := range schemaPatchSQL {
if _, err := sqlDB.Exec(q); err != nil {
return fmt.Errorf("schema patch failed: %v", err)
}
}
return nil
}
// AutoMigrate 执行 ent 自动建表/补齐(只增,不删数据)。供命令行 migrate 使用。
func AutoMigrate(c DatabaseConf) error {
client, sqlDB := MustNewDB(c)
defer sqlDB.Close()
return client.Schema.Create(context.Background())
return EnsureSchema(client, sqlDB)
}
// DropAllTables 清空 public schema 下所有业务表(破坏性,仅供命令行 reset-all 使用)。
+3
View File
@@ -28,6 +28,9 @@ var pathPermMap = map[string]string{
"POST:/api/v1/process-flows/upload": "produce.processflow:upload",
"POST:/api/v1/process-steps": "produce.processflow:edit",
"POST:/api/v1/stations": "produce.station:edit",
// 装机绑定(报工前扫料/撤销,属报工操作域)
"POST:/api/v1/binds": "produce.scan",
"POST:/api/v1/binds/remove": "produce.scan",
// 账号管理(拆分自原 sys.rbac:usersys.account 菜单下增/改/删)
"POST:/api/v1/users": "sys.account:add",
"PUT:/api/v1/users": "sys.account:edit",
@@ -0,0 +1,152 @@
package production
import (
"net/http"
"strconv"
"bj_power_mes/common/httpx"
"bj_power_mes/internal/logic"
"bj_power_mes/internal/svc"
)
func itoa(n int) string { return strconv.Itoa(n) }
// BindReq 绑定提交请求
type BindReq struct {
Sn string `json:"sn"`
OrderNo string `json:"orderNo"`
ProcessCode int `json:"processCode"`
StationNo int `json:"stationNo"`
Operator string `json:"operator"`
Items []logic.BindItemReq `json:"items"`
}
func doBind(svcCtx *svc.ServiceContext, r *http.Request) (any, error) {
var req BindReq
if err := httpx.ParseJSON(r, &req); err != nil {
return nil, err
}
if req.Sn == "" || req.ProcessCode <= 0 {
return nil, nil
}
op := operator(r, req.Operator)
stationNo := ""
if req.StationNo > 0 {
stationNo = itoa(req.StationNo)
}
return logic.New(svcCtx).BindWorkpiece(r.Context(), req.Sn, req.OrderNo, req.ProcessCode, stationNo, op, req.Items)
}
// BindHandler POST /api/v1/binds —— MES 手动报工页/巡检录绑
func BindHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := doBind(svcCtx, r)
if err != nil {
httpx.Fail(w, 3309, err.Error())
return
}
if data == nil {
httpx.BadRequest(w, "sn / processCode 必填")
return
}
httpx.Ok(w, data)
}
}
// BindInternalHandler POST /api/internal/workpiece/binds —— 工位终端扫码绑料
func BindInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := doBind(svcCtx, r)
if err != nil {
httpx.Fail(w, 2111, err.Error())
return
}
if data == nil {
httpx.BadRequest(w, "sn / processCode 必填")
return
}
httpx.Ok(w, data)
}
}
func doUnbind(svcCtx *svc.ServiceContext, r *http.Request) error {
var req struct {
Sn string `json:"sn"`
ProcessCode int `json:"processCode"`
MaterialCode string `json:"materialCode"`
BindValue string `json:"bindValue"`
Operator string `json:"operator"`
}
if err := httpx.ParseJSON(r, &req); err != nil {
return err
}
return logic.New(svcCtx).RemoveBind(r.Context(), req.Sn, req.ProcessCode, req.MaterialCode, req.BindValue, operator(r, req.Operator))
}
// UnbindHandler POST /api/v1/binds/remove —— 撤销误绑(未报工前)
func UnbindHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := doUnbind(svcCtx, r); err != nil {
httpx.Fail(w, 3310, err.Error())
return
}
httpx.OkMessage(w, "已撤销绑定", nil)
}
}
// UnbindInternalHandler POST /api/internal/workpiece/binds/remove
func UnbindInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := doUnbind(svcCtx, r); err != nil {
httpx.Fail(w, 2112, err.Error())
return
}
httpx.OkMessage(w, "已撤销绑定", nil)
}
}
// ---------- 装机绑定:应绑清单/齐套状态查询 ----------
// bindPanelFromReq 通用查询逻辑:按 sn+processCode 返回绑定面板(应绑物料+已绑情况)
func bindPanelFromReq(svcCtx *svc.ServiceContext, r *http.Request) (any, error) {
sn := r.URL.Query().Get("sn")
processCode := atoiDefault(r.URL.Query().Get("processCode"), 0)
if sn == "" || processCode <= 0 {
return nil, nil
}
return logic.New(svcCtx).StationBindPanelData(r.Context(), sn, processCode)
}
// BindPanelHandler GET /api/v1/bind-panel?sn=&processCode=
// 手动报工页在报工前拉取"本工件本工序应装什么料、已装什么",展示引导并做齐套校验。
func BindPanelHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := bindPanelFromReq(svcCtx, r)
if err != nil {
httpx.Fail(w, 3308, err.Error())
return
}
if data == nil {
httpx.BadRequest(w, "缺少 sn / processCode 参数")
return
}
httpx.Ok(w, data)
}
}
// BindPanelInternalHandler GET /api/internal/workpiece/bind-panel?sn=&processCode=
// 工位终端(12 个触控一体机)扫码工件后拉取绑定面板,驱动工人扫码绑料。
func BindPanelInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := bindPanelFromReq(svcCtx, r)
if err != nil {
httpx.Fail(w, 2110, err.Error())
return
}
if data == nil {
httpx.BadRequest(w, "缺少 sn / processCode 参数")
return
}
httpx.Ok(w, data)
}
}
@@ -68,6 +68,15 @@ func RegisterProductionRoutes(server *rest.Server, serverCtx *svc.ServiceContext
server.AddRoute(rest.Route{
Method: http.MethodGet, Path: "/api/internal/dock/pallet", Handler: internal(production.DockPalletInternalHandler(serverCtx)),
})
server.AddRoute(rest.Route{
Method: http.MethodGet, Path: "/api/internal/workpiece/bind-panel", Handler: internal(production.BindPanelInternalHandler(serverCtx)),
})
server.AddRoute(rest.Route{
Method: http.MethodPost, Path: "/api/internal/workpiece/binds", Handler: internal(production.BindInternalHandler(serverCtx)),
})
server.AddRoute(rest.Route{
Method: http.MethodPost, Path: "/api/internal/workpiece/binds/remove", Handler: internal(production.UnbindInternalHandler(serverCtx)),
})
server.AddRoute(rest.Route{
Method: http.MethodPost, Path: "/api/internal/dock/pallet/set", Handler: internal(production.SetDockPalletInternalHandler(serverCtx)),
})
@@ -112,6 +121,9 @@ func RegisterProductionRoutes(server *rest.Server, serverCtx *svc.ServiceContext
{Method: http.MethodPost, Path: "/workpiece/done", Handler: production.DoneWorkpieceHandler(serverCtx)},
{Method: http.MethodGet, Path: "/workpieces", Handler: production.ListWorkpiecesHandler(serverCtx)},
{Method: http.MethodGet, Path: "/trace", Handler: production.TraceHandler(serverCtx)},
{Method: http.MethodGet, Path: "/bind-panel", Handler: production.BindPanelHandler(serverCtx)},
{Method: http.MethodPost, Path: "/binds", Handler: production.BindHandler(serverCtx)},
{Method: http.MethodPost, Path: "/binds/remove", Handler: production.UnbindHandler(serverCtx)},
{Method: http.MethodPost, Path: "/semi/flow", Handler: production.SemiFlowHandler(serverCtx)},
{Method: http.MethodGet, Path: "/semi/flows", Handler: production.ListSemiFlowsHandler(serverCtx)},
+19 -10
View File
@@ -9,15 +9,16 @@ import (
)
type BomItemReq struct {
ProductCode string `json:"productCode"`
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Spec string `json:"spec"`
Unit string `json:"unit"`
ManageMode string `json:"manageMode"`
UnitQty float64 `json:"unitQty"`
LossRate float64 `json:"lossRate"`
RequiredQty float64 `json:"requiredQty"`
ProductCode string `json:"productCode"`
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Spec string `json:"spec"`
Unit string `json:"unit"`
ManageMode string `json:"manageMode"`
ProcessCode *int `json:"processCode"` // 装配工序 1~12;nil=不修改(兼容旧调用/移除时保留);0=显式清除
UnitQty float64 `json:"unitQty"`
LossRate float64 `json:"lossRate"`
RequiredQty float64 `json:"requiredQty"`
SerialSns []string `json:"serialSns"`
}
@@ -33,6 +34,9 @@ func (s *Service) SaveBom(ctx context.Context, productCode string, items []BomIt
u := s.ctx.EntClient.BomItem.UpdateOneID(exist.ID).
SetMaterialName(it.MaterialName).SetSpec(it.Spec).SetUnit(it.Unit).
SetManageMode(it.ManageMode).SetUnitQty(it.UnitQty).SetLossRate(it.LossRate)
if it.ProcessCode != nil {
u = u.SetProcessCode(*it.ProcessCode) // nil 时不覆盖既有工序配置
}
if len(it.SerialSns) > 0 {
u.SetSerialSns(it.SerialSns)
}
@@ -40,10 +44,15 @@ func (s *Service) SaveBom(ctx context.Context, productCode string, items []BomIt
return fmt.Errorf("更新BOM物料 %s 失败: %w", it.MaterialCode, err)
}
} else {
pc := 0
if it.ProcessCode != nil {
pc = *it.ProcessCode
}
b := s.ctx.EntClient.BomItem.Create().
SetProductCode(productCode).SetMaterialCode(it.MaterialCode).
SetMaterialName(it.MaterialName).SetSpec(it.Spec).SetUnit(it.Unit).
SetManageMode(it.ManageMode).SetUnitQty(it.UnitQty).SetLossRate(it.LossRate)
SetManageMode(it.ManageMode).SetProcessCode(pc).
SetUnitQty(it.UnitQty).SetLossRate(it.LossRate)
if len(it.SerialSns) > 0 {
b.SetSerialSns(it.SerialSns)
}
+39 -31
View File
@@ -96,42 +96,50 @@ func (s *Service) Seed(ctx context.Context) error {
}
// 3.1 按钮级权限(块2):前端按钮 + 后端接口 双重校验
buttons := []struct{ code, name, typ, path string }{
{"produce.workorder:add", "工单-新增", "BUTTON", ""},
{"produce.workorder:edit", "工单-编辑", "BUTTON", ""},
{"produce.workorder:delete", "工单-删除", "BUTTON", ""},
{"produce.workorder:dailyplan", "工单-日排产", "BUTTON", ""},
{"produce.bom:edit", "物料清单-维护", "BUTTON", ""},
{"produce.material:generate", "备料单-生成", "BUTTON", ""},
{"produce.plc:send", "工位组合-下发", "BUTTON", ""},
{"produce.product:add", "产品类型-新增", "BUTTON", ""},
{"produce.product:edit", "产品类型-编辑", "BUTTON", ""},
{"produce.product:delete", "产品类型-删除", "BUTTON", ""},
{"produce.processflow:add", "工艺流程-新增", "BUTTON", ""},
{"produce.processflow:edit", "工艺流程-编辑", "BUTTON", ""},
{"produce.processflow:delete", "工艺流程-删除", "BUTTON", ""},
{"produce.processflow:upload", "工艺流程-上传图纸", "BUTTON", ""},
{"produce.station:edit", "工位-绑定", "BUTTON", ""},
{"sys.account:add", "账号-新增", "BUTTON", ""},
{"sys.account:edit", "账号-编辑", "BUTTON", ""},
{"sys.account:delete", "账号-删除", "BUTTON", ""},
{"sys.role:add", "角色-新增", "BUTTON", ""},
{"sys.role:edit", "角色-编辑", "BUTTON", ""},
{"sys.role:delete", "角色-删除", "BUTTON", ""},
{"sys.role:perm", "角色-权限定义", "BUTTON", ""},
{"sys.inspect:checkin", "巡检-开工签到", "BUTTON", ""},
{"sys.inspect:point", "巡检-工位点检", "BUTTON", ""},
{"sys.inspect:process", "巡检-过程巡检", "BUTTON", ""},
{"sys.inspect:done", "巡检-完工确认", "BUTTON", ""},
{"sys.inspect:alarm", "巡检-异常上报", "BUTTON", ""},
{"sys.inspect:view", "巡检-记录查看", "BUTTON", ""},
// parent = 所属菜单权限码:每个按钮显式声明挂在哪个菜单下,前端据此组装权限树,
// 不再用 code.startsWith(菜单+':') 这类前缀通配推断。
buttons := []struct{ code, name, typ, path, parent string }{
{"produce.workorder:add", "工单-新增", "BUTTON", "", "produce.workorder"},
{"produce.workorder:edit", "工单-编辑", "BUTTON", "", "produce.workorder"},
{"produce.workorder:delete", "工单-删除", "BUTTON", "", "produce.workorder"},
{"produce.workorder:dailyplan", "工单-日排产", "BUTTON", "", "produce.workorder"},
{"produce.bom:edit", "物料清单-维护", "BUTTON", "", "produce.bom"},
{"produce.material:generate", "备料单-生成", "BUTTON", "", "produce.material"},
{"produce.plc:send", "工位组合-下发", "BUTTON", "", "produce.plc"},
{"produce.product:add", "产品类型-新增", "BUTTON", "", "produce.product"},
{"produce.product:edit", "产品类型-编辑", "BUTTON", "", "produce.product"},
{"produce.product:delete", "产品类型-删除", "BUTTON", "", "produce.product"},
{"produce.processflow:add", "工艺流程-新增", "BUTTON", "", "produce.processflow"},
{"produce.processflow:edit", "工艺流程-编辑", "BUTTON", "", "produce.processflow"},
{"produce.processflow:delete", "工艺流程-删除", "BUTTON", "", "produce.processflow"},
{"produce.processflow:upload", "工艺流程-上传图纸", "BUTTON", "", "produce.processflow"},
{"produce.station:edit", "工位-绑定", "BUTTON", "", "produce.station"},
{"sys.account:add", "账号-新增", "BUTTON", "", "sys.account"},
{"sys.account:edit", "账号-编辑", "BUTTON", "", "sys.account"},
{"sys.account:delete", "账号-删除", "BUTTON", "", "sys.account"},
{"sys.role:add", "角色-新增", "BUTTON", "", "sys.role"},
{"sys.role:edit", "角色-编辑", "BUTTON", "", "sys.role"},
{"sys.role:delete", "角色-删除", "BUTTON", "", "sys.role"},
{"sys.role:perm", "角色-权限定义", "BUTTON", "", "sys.role"},
{"sys.inspect:checkin", "巡检-开工签到", "BUTTON", "", "sys.inspect"},
{"sys.inspect:point", "巡检-工位点检", "BUTTON", "", "sys.inspect"},
{"sys.inspect:process", "巡检-过程巡检", "BUTTON", "", "sys.inspect"},
{"sys.inspect:done", "巡检-完工确认", "BUTTON", "", "sys.inspect"},
{"sys.inspect:alarm", "巡检-异常上报", "BUTTON", "", "sys.inspect"},
{"sys.inspect:view", "巡检-记录查看", "BUTTON", "", "sys.inspect"},
}
for _, b := range buttons {
if s.ctx.EntClient.Permission.Query().Where(permission.Code(b.code)).ExistX(ctx) {
exist, err := s.ctx.EntClient.Permission.Query().Where(permission.Code(b.code)).Only(ctx)
if err == nil && exist != nil {
// 已存在:幂等回填 parentCode(避免旧数据没有父子关系导致权限树漏按钮)
if exist.ParentCode != b.parent {
_ = exist.Update().SetParentCode(b.parent).Exec(ctx)
}
continue
}
_ = s.ctx.EntClient.Permission.Create().
SetCode(b.code).SetName(b.name).SetType(b.typ).SetPath(b.path).Exec(ctx)
SetCode(b.code).SetName(b.name).SetType(b.typ).SetPath(b.path).
SetParentCode(b.parent).Exec(ctx)
}
// 3.2 旧版权限码迁移(sys.rbac 家族 → sys.account / sys.role,幂等)
+26 -2
View File
@@ -100,9 +100,12 @@ type ReportProcessReq struct {
ProcessCode int `json:"processCode"`
StationNo int `json:"stationNo"`
Steps []StepDataReq `json:"steps"`
Binds []BindItemReq `json:"binds"` // 装机绑定(本工序装配物料:结构件批次/精密件SN),随报工原子提交
}
// ReportProcess 工位报工:写报工实绩 + 步骤考核(step_data,扭矩在RANGE自动判定 OK/NG
// ReportProcess 工位报工:先应用装机绑定并做强校验(齐套才放行),再写报工实绩 + 步骤考核。
// 绑料是"落到工艺流程"的强约束:产品 BOM 中标记为本工序(processCode)的物料必须绑齐,
// 精密件按单台用量逐颗SN、结构件至少一个批次号;缺料直接拒绝报工并返回缺料清单。
func (s *Service) ReportProcess(ctx context.Context, req ReportProcessReq, operator string) error {
if req.Sn == "" || req.ProcessCode == 0 {
return errors.New("sn 与 processCode 必填")
@@ -111,6 +114,19 @@ func (s *Service) ReportProcess(ctx context.Context, req ReportProcessReq, opera
if err != nil {
return errors.New("工件未进线登记")
}
// 阶段1:装机绑定(随报工提交)→ 落库后强校验齐套
stationNo := ""
if req.StationNo > 0 {
stationNo = itoa(req.StationNo)
}
if _, err := s.applyBinds(ctx, req.Sn, wp.OrderNo, req.ProcessCode, stationNo, operator, req.Binds); err != nil {
return err
}
if miss, err := s.validateStationBinds(ctx, req.Sn, req.ProcessCode); err != nil {
return err
} else if miss != "" {
return errors.New(miss)
}
now := time.Now()
proc, err := s.ctx.EntClient.WorkpieceProcess.Create().
SetSn(req.Sn).SetProcessCode(req.ProcessCode).
@@ -177,12 +193,18 @@ type DoneReq struct {
SerialItems []string `json:"serialItems"` // 精密件SN
}
// DoneWorkpiece 完工:登记成品并生成关联追溯
// DoneWorkpiece 完工:先兜底校验全部工序装配物料齐套(工艺流程强校验),再登记成品并生成关联追溯
func (s *Service) DoneWorkpiece(ctx context.Context, req DoneReq, operator string) error {
wp, err := s.ctx.EntClient.Workpiece.Query().Where(workpiece.Sn(req.Sn)).First(ctx)
if err != nil {
return errors.New("工件不存在")
}
// 兜底强校验:工艺路线中每个工序的装配物料都须绑齐(BOM 未配置工序时自动放行)
if miss, err := s.validateAllProcessBinds(ctx, req.Sn); err != nil {
return err
} else if miss != "" {
return errors.New("完工前必须绑齐全部装配物料:" + miss)
}
now := time.Now()
_, _ = s.ctx.EntClient.Workpiece.UpdateOneID(wp.ID).
SetStatus("DONE").SetDoneAt(now).Save(ctx)
@@ -222,6 +244,7 @@ func (s *Service) Trace(ctx context.Context, sn string) (map[string]any, error)
Where(associationtrace.FinishedSn(sn)).First(ctx)
torque, _ := s.ctx.EntClient.TorqueRecord.Query().
Where(torquerecord.Sn(sn)).Order(ent.Desc(torquerecord.FieldTime)).Limit(200).All(ctx)
bindVO, _ := s.BuildBindTrace(ctx, sn)
return map[string]any{
"workpiece": wp,
@@ -229,6 +252,7 @@ func (s *Service) Trace(ctx context.Context, sn string) (map[string]any, error)
"stepData": steps,
"associationTrace": assoc,
"torqueRecords": torque,
"bindTrace": bindVO,
}, nil
}
@@ -0,0 +1,372 @@
package logic
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strings"
"bj_power_mes/ent"
"bj_power_mes/ent/bomitem"
"bj_power_mes/ent/workpiece"
"bj_power_mes/ent/workpiecebind"
"bj_power_mes/ent/workpieceprocess"
)
// RemoveBind 撤销一条绑定(仅允许未报工前的误扫纠正;报工后不可撤销)
func (s *Service) RemoveBind(ctx context.Context, sn string, processCode int, materialCode, bindValue, operator string) error {
if sn == "" || materialCode == "" || bindValue == "" {
return errors.New("sn/materialCode/bindValue 必填")
}
// 该工序已报工(存在实绩)则不允许撤销
done, err := s.ctx.EntClient.WorkpieceProcess.Query().
Where(workpieceprocess.Sn(sn), workpieceprocess.ProcessCode(processCode)).Exist(ctx)
if err != nil {
return err
}
if done {
return errors.New("该工序已报工,不能撤销装配绑定")
}
n, err := s.ctx.EntClient.WorkpieceBind.Delete().
Where(workpiecebind.Sn(sn), workpiecebind.ProcessCode(processCode),
workpiecebind.MaterialCode(materialCode), workpiecebind.BindValue(bindValue)).Exec(ctx)
if err != nil {
return err
}
if n == 0 {
return errors.New("未找到该绑定记录")
}
s.ctx.EventLog.Write(ctx, "workpiece.bind.remove", "", operator, "workpiece_bind", sn,
"撤销装配绑定", map[string]any{"processCode": processCode, "materialCode": materialCode, "bindValue": bindValue})
return nil
}
// BindWorkpiece 独立绑定接口:工人在工位扫一件绑一件(报工前的逐条扫码/防错),
// 与报工携带 binds 等价,落库后立即返回结果供前端即时反馈(SN 重复/料不匹配当场报错)。
func (s *Service) BindWorkpiece(ctx context.Context, sn, orderNo string, processCode int, stationNo string, operator string, items []BindItemReq) (map[string]any, error) {
bound, err := s.applyBinds(ctx, sn, orderNo, processCode, stationNo, operator, items)
if err != nil {
return nil, err
}
if bound > 0 {
s.ctx.EventLog.Write(ctx, "workpiece.bind", orderNo, operator, "workpiece_bind", sn,
"装机绑定物料", map[string]any{"processCode": processCode, "count": bound})
s.notifyDashboard()
}
// 返回最新面板,前端据此刷新齐套状态
panel, err := s.StationBindPanelData(ctx, sn, processCode)
if err != nil {
return map[string]any{"bound": bound}, nil
}
return map[string]any{"bound": bound, "panel": panel}, nil
}
// ---------- 装机绑定(工位装配物料追溯 + 工艺流程强校验) ----------
// BindItemReq 单条装机绑定请求(报工时随 steps 一并提交)
type BindItemReq struct {
MaterialCode string `json:"materialCode"`
BindValue string `json:"bindValue"` // 结构件=批次号;精密件=SN
}
// bindTarget 由 BOM 解析出的某工序应装物料定义
type bindTarget struct {
MaterialCode string
MaterialName string
Spec string
Unit string
ManageMode string // 1 批次(结构件) / 2 SN(精密件)
UnitQty float64
}
// workpieceProduct 解析工件所属产品编码(sn → workpiece → workOrder → productCode
func (s *Service) workpieceProduct(ctx context.Context, sn string) (*ent.Workpiece, string, error) {
wp, err := s.ctx.EntClient.Workpiece.Query().Where(workpiece.Sn(sn)).First(ctx)
if err != nil {
return nil, "", errors.New("工件未进线登记")
}
if wp.WorkOrderId <= 0 {
return wp, "", errors.New("工件未关联工单")
}
wo, err := s.ctx.EntClient.WorkOrder.Get(ctx, wp.WorkOrderId)
if err != nil || wo == nil {
return wp, "", errors.New("工件所属工单不存在")
}
return wp, wo.ProductCode, nil
}
// stationBindTargets 取某产品在某工序(processCode>0)要求装配的物料清单
func (s *Service) stationBindTargets(ctx context.Context, productCode string, processCode int) ([]bindTarget, error) {
items, err := s.ctx.EntClient.BomItem.Query().
Where(bomitem.ProductCode(productCode), bomitem.ProcessCode(processCode)).All(ctx)
if err != nil {
return nil, err
}
out := make([]bindTarget, 0, len(items))
for _, it := range items {
out = append(out, bindTarget{
MaterialCode: it.MaterialCode, MaterialName: it.MaterialName, Spec: it.Spec,
Unit: it.Unit, ManageMode: it.ManageMode, UnitQty: it.UnitQty,
})
}
return out, nil
}
// applyBinds 报工时应用装机绑定(落库 + 防错校验):
// ① 料必须属于该产品 BOM 且装配工序配置为本工序(processCode==当前工序);
// ② 精密件 SN 全局防重:已被其他工件绑定则拒绝(防错拿/防重复装机);
// ③ 同一工件同一料同一绑定值幂等(重复扫码不报错,静默去重)。
// 返回绑定成功条数。
func (s *Service) applyBinds(ctx context.Context, sn, orderNo string, processCode int, stationNo string, operator string, items []BindItemReq) (int, error) {
if len(items) == 0 {
return 0, nil
}
wp, productCode, err := s.workpieceProduct(ctx, sn)
if err != nil {
return 0, err
}
if wp.Status == "DONE" || wp.Status == "SCRAPPED" {
return 0, errors.New("已完工/报废工件不能再绑定物料")
}
// 产品 BOM 全量索引:materialCode → target(校验料合法性 + 取物料快照)
bomMap := map[string]*ent.BomItem{}
allBom, _ := s.ctx.EntClient.BomItem.Query().
Where(bomitem.ProductCode(productCode)).All(ctx)
for _, b := range allBom {
bomMap[b.MaterialCode] = b
}
bound := 0
for _, it := range items {
mc := strings.TrimSpace(it.MaterialCode)
bv := strings.TrimSpace(it.BindValue)
if mc == "" || bv == "" {
return bound, errors.New("物料编码与绑定值不能为空")
}
bi, ok := bomMap[mc]
if !ok {
return bound, fmt.Errorf("物料 %s 不在该产品的物料清单(BOM)中,不能绑定", mc)
}
if bi.ProcessCode != processCode {
return bound, fmt.Errorf("物料 %s 配置在工序%d装配,本工位(工序%d)不可绑定", mc, bi.ProcessCode, processCode)
}
// 同工件同料同值幂等
exists, _ := s.ctx.EntClient.WorkpieceBind.Query().
Where(workpiecebind.Sn(sn), workpiecebind.MaterialCode(mc), workpiecebind.BindValue(bv)).Exist(ctx)
if exists {
continue
}
// 精密件 SN 全局防重
if bi.ManageMode == "2" {
other, _ := s.ctx.EntClient.WorkpieceBind.Query().
Where(workpiecebind.BindValue(bv), workpiecebind.BindType("SN"), workpiecebind.SnNEQ(sn)).First(ctx)
if other != nil {
return bound, fmt.Errorf("精密件SN %s 已绑定到工件 %s(工序%d),不能重复装机", bv, other.Sn, other.ProcessCode)
}
}
bindType := "BATCH"
if bi.ManageMode == "2" {
bindType = "SN"
}
if err := s.ctx.EntClient.WorkpieceBind.Create().
SetSn(sn).SetOrderNo(orderNo).SetProcessCode(processCode).
SetStationNo(stationNo).SetMaterialCode(mc).SetMaterialName(bi.MaterialName).
SetSpec(bi.Spec).SetUnit(bi.Unit).SetManageMode(bi.ManageMode).
SetBindValue(bv).SetBindType(bindType).SetOperator(operator).
Exec(ctx); err != nil {
return bound, err
}
bound++
}
return bound, nil
}
// validateStationBinds 校验某工件在某工序的装配物料是否绑齐(工艺流程强校验核心):
// 返回缺料明细(空串=齐套)。判定:精密件(SN) 绑定的不同SN数 ≥ 单台用量;结构件(批次) 至少绑定1个批次号。
// 若该产品该工序未配置任何装配物料(processCode未配置),视为不启用绑定校验,放行。
func (s *Service) validateStationBinds(ctx context.Context, sn string, processCode int) (string, error) {
_, productCode, err := s.workpieceProduct(ctx, sn)
if err != nil {
return "", err
}
targets, err := s.stationBindTargets(ctx, productCode, processCode)
if err != nil {
return "", err
}
if len(targets) == 0 {
return "", nil // 该工序未配置装配物料 → 不校验
}
// 已绑定明细:按 物料编码 → 绑定值集合
binds, _ := s.ctx.EntClient.WorkpieceBind.Query().
Where(workpiecebind.Sn(sn), workpiecebind.ProcessCode(processCode)).All(ctx)
boundMap := map[string]map[string]bool{}
for _, b := range binds {
if boundMap[b.MaterialCode] == nil {
boundMap[b.MaterialCode] = map[string]bool{}
}
boundMap[b.MaterialCode][b.BindValue] = true
}
missing := []string{}
for _, t := range targets {
set := boundMap[t.MaterialCode]
boundCount := len(set)
need := int(math.Ceil(t.UnitQty))
if need < 1 {
need = 1
}
if t.ManageMode == "2" { // 精密件 SN:需绑满单台用量
if boundCount < need {
missing = append(missing, fmt.Sprintf("%s(%s):需绑定%d颗SN,已绑%d颗", t.MaterialCode, t.MaterialName, need, boundCount))
}
} else { // 结构件批次:至少绑定1个批次号
if boundCount < 1 {
missing = append(missing, fmt.Sprintf("%s(%s):未绑定批次号", t.MaterialCode, t.MaterialName))
}
}
}
if len(missing) == 0 {
return "", nil
}
return "装配物料未绑齐:" + strings.Join(missing, ""), nil
}
// validateAllProcessBinds 完工兜底:校验工件工艺路线中每个工序的装配物料齐套
func (s *Service) validateAllProcessBinds(ctx context.Context, sn string) (string, error) {
wp, _, err := s.workpieceProduct(ctx, sn)
if err != nil {
return "", err
}
missing := []string{}
for _, code := range ParseProcessSeq(wp.ProcessSeq) {
msg, err := s.validateStationBinds(ctx, sn, code)
if err != nil {
return "", err
}
if msg != "" {
missing = append(missing, fmt.Sprintf("工序%d%s", code, msg))
}
}
if len(missing) == 0 {
return "", nil
}
return strings.Join(missing, ""), nil
}
// ListWorkpieceBinds 查询工件的装机绑定明细
func (s *Service) ListWorkpieceBinds(ctx context.Context, sn string) ([]*ent.WorkpieceBind, error) {
q := s.ctx.EntClient.WorkpieceBind.Query()
if sn != "" {
q = q.Where(workpiecebind.Sn(sn))
}
return q.Order(ent.Asc(workpiecebind.FieldProcessCode), ent.Asc(workpiecebind.FieldID)).Limit(2000).All(ctx)
}
// BindTraceVO 追溯页:装机绑定明细 + 每工序齐套状态
type BindTraceVO struct {
Binds []*ent.WorkpieceBind `json:"binds"`
Complete bool `json:"complete"`
Missing string `json:"missing"`
}
// BuildBindTrace 组装某工件装机绑定追溯(含全工序齐套状态,供成品档案/追溯页展示)
func (s *Service) BuildBindTrace(ctx context.Context, sn string) (*BindTraceVO, error) {
binds, err := s.ListWorkpieceBinds(ctx, sn)
if err != nil {
return nil, err
}
missing, err := s.validateAllProcessBinds(ctx, sn)
if err != nil {
return nil, err
}
return &BindTraceVO{Binds: binds, Complete: missing == "", Missing: missing}, nil
}
// BindRequiredItem 应绑清单条目(工位终端/手动报工展示引导 + 前端齐套计算)
type BindRequiredItem struct {
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Spec string `json:"spec"`
Unit string `json:"unit"`
ManageMode string `json:"manageMode"`
UnitQty float64 `json:"unitQty"`
Need int `json:"need"` // 需绑数量
Bound []string `json:"bound"` // 已绑定的批次号/SN 列表
BoundCount int `json:"boundCount"`
Complete bool `json:"complete"`
}
// StationBindPanel 某工件在某工序的装配绑定面板数据(应绑清单 + 已绑情况)
type StationBindPanel struct {
Enabled bool `json:"enabled"` // 本工序是否启用装配物料绑定
Required []BindRequiredItem `json:"required"`
Complete bool `json:"complete"`
Missing string `json:"missing"`
StationNo int `json:"stationNo"`
ProcessCode int `json:"processCode"`
LastBinds []*ent.WorkpieceBind `json:"lastBinds"` // 最近10条绑定记录
StationNames map[int]string `json:"-"`
}
// StationBindPanelData 供工位终端/手动报工页在报工前拉取:本工件本工序该装什么、已装什么
func (s *Service) StationBindPanelData(ctx context.Context, sn string, processCode int) (*StationBindPanel, error) {
_, productCode, err := s.workpieceProduct(ctx, sn)
if err != nil {
return nil, err
}
targets, err := s.stationBindTargets(ctx, productCode, processCode)
if err != nil {
return nil, err
}
panel := &StationBindPanel{Enabled: len(targets) > 0, ProcessCode: processCode, StationNo: processCode}
// 全量绑定(齐套判定必须按全量统计,不能用 limit 截断,否则多料/多SN会误判缺料)
allBinds, _ := s.ctx.EntClient.WorkpieceBind.Query().
Where(workpiecebind.Sn(sn), workpiecebind.ProcessCode(processCode)).All(ctx)
boundMap := map[string]map[string]bool{}
for _, b := range allBinds {
if boundMap[b.MaterialCode] == nil {
boundMap[b.MaterialCode] = map[string]bool{}
}
boundMap[b.MaterialCode][b.BindValue] = true
}
for _, t := range targets {
need := int(math.Ceil(t.UnitQty))
if need < 1 {
need = 1
}
bound := make([]string, 0, len(boundMap[t.MaterialCode]))
for v := range boundMap[t.MaterialCode] {
bound = append(bound, v)
}
sort.Strings(bound)
item := BindRequiredItem{
MaterialCode: t.MaterialCode, MaterialName: t.MaterialName, Spec: t.Spec, Unit: t.Unit,
ManageMode: t.ManageMode, UnitQty: t.UnitQty, Need: need, Bound: bound,
BoundCount: len(bound),
}
if t.ManageMode == "2" {
item.Complete = len(bound) >= need
} else {
item.Complete = len(bound) >= 1
}
panel.Required = append(panel.Required, item)
}
allComplete := true
for _, r := range panel.Required {
if !r.Complete {
allComplete = false
break
}
}
panel.Complete = allComplete
if !allComplete {
panel.Missing, _ = s.validateStationBinds(ctx, sn, processCode)
}
// 最近绑定(仅展示用)
sort.Slice(allBinds, func(i, j int) bool { return allBinds[i].ID > allBinds[j].ID })
if len(allBinds) > 10 {
allBinds = allBinds[:10]
}
panel.LastBinds = allBinds
return panel, nil
}