Files
bj_power/bj_power_mes/internal/logic/workpiece_bind.go
T
SunYF f274da044c feat: 重构BOM架构,新增基础设置与虚拟工位管理功能
本次重构删除原有BOM物料清单表,改用工单工艺组合×工艺物料清单作为唯一用料来源;新增系统基础设置表支持WMS地址、日志保留天数等配置,新增虚拟工位作业管理后台接口与前端页签控制功能,同时优化工位号校验逻辑、事件日志自动清理与工单用料查询能力。
2026-09-23 11:41:28 +08:00

422 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package logic
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strings"
"bj_power_mes/ent"
"bj_power_mes/ent/flowmaterial"
"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, flowId 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.FlowId(flowId)).Exist(ctx)
if err != nil {
return err
}
if done {
return errors.New("该工艺已报工,不能撤销装配绑定")
}
n, err := s.ctx.EntClient.WorkpieceBind.Delete().
Where(workpiecebind.Sn(sn), workpiecebind.FlowId(flowId),
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{"flowId": flowId, "materialCode": materialCode, "bindValue": bindValue})
return nil
}
// BindWorkpiece 独立绑定接口:工人在工位扫一件绑一件(报工前的逐条扫码/防错),
// 与报工携带 binds 等价,落库后立即返回结果供前端即时反馈(SN 重复/料不匹配当场报错)。
func (s *Service) BindWorkpiece(ctx context.Context, sn, orderNo string, flowId int, stationNo string, operator string, items []BindItemReq) (map[string]any, error) {
bound, err := s.applyBinds(ctx, s.ctx.EntClient, sn, orderNo, flowId, 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{"flowId": flowId, "count": bound})
s.notifyDashboard()
}
// 返回最新面板,前端据此刷新齐套状态
panel, err := s.StationBindPanelData(ctx, sn, flowId)
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 某工艺要求装配的物料定义(源自工艺物料清单 flow_material
type bindTarget struct {
MaterialCode string
MaterialName string
Spec string
Unit string
ManageMode string // 1 批次(结构件) / 2 SN(电气件)
UnitQty float64
}
// workpieceProduct 解析工件所属产品编码与工单(sn → workpiece → workOrder
func (s *Service) workpieceProduct(ctx context.Context, client *ent.Client, sn string) (*ent.Workpiece, *ent.WorkOrder, error) {
wp, err := client.Workpiece.Query().Where(workpiece.Sn(sn)).First(ctx)
if err != nil {
return nil, nil, errors.New("工件未进线登记")
}
if wp.WorkOrderId <= 0 {
return wp, nil, errors.New("工件未关联工单")
}
wo, err := client.WorkOrder.Get(ctx, wp.WorkOrderId)
if err != nil || wo == nil {
return wp, nil, errors.New("工件所属工单不存在")
}
return wp, wo, nil
}
// stationBindTargets 取某工艺(flowId)要求装配的物料清单(工艺物料清单 flow_material)。
func (s *Service) stationBindTargets(ctx context.Context, client *ent.Client, flowId int) ([]bindTarget, error) {
items, err := client.FlowMaterial.Query().
Where(flowmaterial.FlowId(flowId)).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 报工时应用装机绑定(落库 + 防错校验):
// ① 料必须配置在该工位的工艺物料清单(flow_material)中;
// ② 电气件 SN 全局防重:已被其他工件绑定则拒绝(防错拿/防重复装机);
// ③ 同一工件同一料同一绑定值幂等(重复扫码不报错,静默去重)。
// 返回绑定成功条数。
func (s *Service) applyBinds(ctx context.Context, client *ent.Client, sn, orderNo string, flowId int, stationNo string, operator string, items []BindItemReq) (int, error) {
if len(items) == 0 {
return 0, nil
}
wp, _, err := s.workpieceProduct(ctx, client, sn)
if err != nil {
return 0, err
}
if wp.Status == "DONE" || wp.Status == "SCRAPPED" {
return 0, errors.New("已完工/报废工件不能再绑定物料")
}
// 本工艺物料清单全集:materialCode → 定义(校验料合法性 + 取物料快照)
fmMap := map[string]*ent.FlowMaterial{}
targets, err := s.stationBindTargets(ctx, client, flowId)
if err != nil {
return 0, err
}
for i := range targets {
fmMap[targets[i].MaterialCode] = &ent.FlowMaterial{
MaterialCode: targets[i].MaterialCode, MaterialName: targets[i].MaterialName,
Spec: targets[i].Spec, Unit: targets[i].Unit,
ManageMode: targets[i].ManageMode, UnitQty: targets[i].UnitQty,
}
}
bound := 0
for _, it := range items {
mc := strings.TrimSpace(it.MaterialCode)
bv := strings.TrimSpace(it.BindValue)
if mc == "" || bv == "" {
return bound, errors.New("物料编码与绑定值不能为空")
}
fm, ok := fmMap[mc]
if !ok {
return bound, fmt.Errorf("物料 %s 未配置在工艺「%s」的物料清单,不能绑定", mc, s.flowNameById(ctx, flowId))
}
manageMode := fm.ManageMode
if manageMode == "" {
manageMode = "1"
}
// 同工件同料同值幂等
exists, _ := client.WorkpieceBind.Query().
Where(workpiecebind.Sn(sn), workpiecebind.MaterialCode(mc), workpiecebind.BindValue(bv)).Exist(ctx)
if exists {
continue
}
// 电气件 SN 全局防重
if manageMode == "2" {
other, _ := client.WorkpieceBind.Query().
Where(workpiecebind.BindValue(bv), workpiecebind.BindType("SN"), workpiecebind.SnNEQ(sn)).First(ctx)
if other != nil {
return bound, fmt.Errorf("电气件SN %s 已绑定到工件 %s(工艺「%s」),不能重复装机",
bv, other.Sn, s.flowNameById(ctx, other.FlowId))
}
}
bindType := "BATCH"
if manageMode == "2" {
bindType = "SN"
}
if err := client.WorkpieceBind.Create().
SetSn(sn).SetOrderNo(orderNo).SetFlowId(flowId).
SetStationNo(stationNo).SetMaterialCode(mc).SetMaterialName(fm.MaterialName).
SetSpec(fm.Spec).SetUnit(fm.Unit).SetManageMode(manageMode).
SetBindValue(bv).SetBindType(bindType).SetOperator(operator).
Exec(ctx); err != nil {
return bound, err
}
bound++
}
return bound, nil
}
// validateStationBinds 校验某工件在某工艺的装配物料是否绑齐(齐套强校验核心):
// 返回缺料明细(空串=齐套)。判定:电气件(SN) 绑定的不同SN数 ≥ 单台用量;结构件(批次) 至少绑定1个批次号。
// 该工艺未配置物料清单时视为不启用绑定校验,放行。
func (s *Service) validateStationBinds(ctx context.Context, client *ent.Client, sn string, flowId int) (string, error) {
if _, _, err := s.workpieceProduct(ctx, client, sn); err != nil {
return "", err
}
targets, err := s.stationBindTargets(ctx, client, flowId)
if err != nil {
return "", err
}
if len(targets) == 0 {
return "", nil // 该工艺未配置装配物料 → 不校验
}
// 已绑定明细:按 物料编码 → 绑定值集合
binds, _ := client.WorkpieceBind.Query().
Where(workpiecebind.Sn(sn), workpiecebind.FlowId(flowId)).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
}
// validateAllFlows 完工兜底:按工单工艺组合(唯一路线源头)逐工艺校验装配物料齐套。
// 同一工艺绑多工位时只校验一次(按 FlowId 去重)。
func (s *Service) validateAllFlows(ctx context.Context, client *ent.Client, sn string, wo *ent.WorkOrder) (string, error) {
if _, _, err := s.workpieceProduct(ctx, client, sn); err != nil {
return "", err
}
items, _ := RouteStationsFromWO(ctx, client, wo)
missing := []string{}
seen := map[int]bool{}
for _, it := range items {
if seen[it.FlowId] {
continue
}
seen[it.FlowId] = true
msg, err := s.validateStationBinds(ctx, client, sn, it.FlowId)
if err != nil {
return "", err
}
if msg != "" {
missing = append(missing, fmt.Sprintf("工艺「%s」:%s", s.flowNameById(ctx, it.FlowId), 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.FieldFlowId), 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"`
BomTree []BindNode `json:"bomTree"` // 递归展开成品零部件到叶子的物料组成树
}
// BindNode 追溯用的物料组成节点(递归展开成品零部件,不依赖 WMS)
type BindNode struct {
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Spec string `json:"spec"`
Unit string `json:"unit"`
ManageMode string `json:"manageMode"` // 1 批次(结构件) / 2 SN(电气件)
Qty float64 `json:"qty"` // 折算到单台成品的用量
Level int `json:"level"` // 0=顶层装配物料,1+=下级
Children []BindNode `json:"children,omitempty"`
}
// BuildBindTrace 组装某工件装机绑定追溯(含全工艺齐套状态 + 物料组成树,供成品档案/追溯页展示)
func (s *Service) BuildBindTrace(ctx context.Context, sn string) (*BindTraceVO, error) {
binds, err := s.ListWorkpieceBinds(ctx, sn)
if err != nil {
return nil, err
}
_, wo, err := s.workpieceProduct(ctx, s.ctx.EntClient, sn)
if err != nil {
return nil, err
}
missing, err := s.validateAllFlows(ctx, s.ctx.EntClient, sn, wo)
if err != nil {
return nil, err
}
// 物料组成:按工单工艺组合×工艺物料清单平铺一层(BOM 已删除,无多级树)
var tree []BindNode
for _, it := range s.WorkOrderUnitUsage(ctx, wo) {
tree = append(tree, BindNode{
MaterialCode: it.MaterialCode, MaterialName: it.MaterialName,
Spec: it.Spec, Unit: it.Unit, ManageMode: it.ManageMode,
Qty: it.UnitQty, Level: 0,
})
}
return &BindTraceVO{Binds: binds, Complete: missing == "", Missing: missing, BomTree: tree}, 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"` // 工位号(从工单工艺组合该 flow 条目派生)
FlowId int `json:"flowId"`
LastBinds []*ent.WorkpieceBind `json:"lastBinds"` // 最近10条绑定记录
StationNames map[int]string `json:"-"`
}
// StationBindPanelData 供工位终端在报工前拉取:本工件本工艺该装什么、已装什么
func (s *Service) StationBindPanelData(ctx context.Context, sn string, flowId int) (*StationBindPanel, error) {
_, wo, err := s.workpieceProduct(ctx, s.ctx.EntClient, sn)
if err != nil {
return nil, err
}
targets, err := s.stationBindTargets(ctx, s.ctx.EntClient, flowId)
if err != nil {
return nil, err
}
// 工位号从工单工艺组合该 flow 的条目派生(组合是唯一路线源头)
stationNo := 0
if items, _ := RouteStationsFromWO(ctx, s.ctx.EntClient, wo); len(items) > 0 {
for _, it := range items {
if it.FlowId == flowId {
stationNo = it.StationNo
break
}
}
}
panel := &StationBindPanel{Enabled: len(targets) > 0, FlowId: flowId, StationNo: stationNo}
// 全量绑定(齐套判定必须按全量统计,不能用 limit 截断,否则多料/多SN会误判缺料)
allBinds, _ := s.ctx.EntClient.WorkpieceBind.Query().
Where(workpiecebind.Sn(sn), workpiecebind.FlowId(flowId)).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, s.ctx.EntClient, sn, flowId)
}
// 最近绑定(仅展示用)
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
}