1. 移除工单、工件、日排产中的冗余工位组合/接驳台字段 2. 新增工位接驳台编码字段与唯一约束,关联WMS接驳台主数据 3. 重构日排产为工位产量分配模式,替代原接驳台列表 4. 新增备料单工位级字段与分批出库支持 5. 移除定时同步可生产数量,改为WMS实时拉取接口 6. 过滤操作日志,排除登录/退出相关日志 7. 调整仪表盘工序展示逻辑为今日派工路线 8. 新增接驳台维护菜单与基础数据
436 lines
17 KiB
Go
436 lines
17 KiB
Go
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, s.ctx.EntClient, 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 解析工件所属产品编码与工单锁定的 BOM 名称(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 取某工单(产品+BOM)在某工序(processCode>0)要求装配的物料清单。
|
||
// 一型号多份 BOM 并存,必须按工单锁定的 bomName 过滤,否则不同厂家料单互相串。
|
||
func (s *Service) stationBindTargets(ctx context.Context, client *ent.Client, productCode, bomName string, processCode int) ([]bindTarget, error) {
|
||
items, err := client.BomItem.Query().
|
||
Where(bomitem.ProductCode(productCode), bomitem.BomName(bomNameOrDefault(bomName)), 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, client *ent.Client, sn, orderNo string, processCode int, stationNo string, operator string, items []BindItemReq) (int, error) {
|
||
if len(items) == 0 {
|
||
return 0, nil
|
||
}
|
||
wp, wo, err := s.workpieceProduct(ctx, client, sn)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
productCode := wo.ProductCode
|
||
if wp.Status == "DONE" || wp.Status == "SCRAPPED" {
|
||
return 0, errors.New("已完工/报废工件不能再绑定物料")
|
||
}
|
||
// 工单锁定 BOM 的全量索引:materialCode → target(校验料合法性 + 取物料快照)
|
||
bomMap := map[string]*ent.BomItem{}
|
||
allBom, _ := client.BomItem.Query().
|
||
Where(bomitem.ProductCode(productCode), bomitem.BomName(bomNameOrDefault(""))).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, _ := client.WorkpieceBind.Query().
|
||
Where(workpiecebind.Sn(sn), workpiecebind.MaterialCode(mc), workpiecebind.BindValue(bv)).Exist(ctx)
|
||
if exists {
|
||
continue
|
||
}
|
||
// 电气件 SN 全局防重
|
||
if bi.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(工序%d),不能重复装机", bv, other.Sn, other.ProcessCode)
|
||
}
|
||
}
|
||
bindType := "BATCH"
|
||
if bi.ManageMode == "2" {
|
||
bindType = "SN"
|
||
}
|
||
if err := client.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, client *ent.Client, sn string, processCode int) (string, error) {
|
||
_, wo, err := s.workpieceProduct(ctx, client, sn)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
targets, err := s.stationBindTargets(ctx, client, wo.ProductCode, "", processCode)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if len(targets) == 0 {
|
||
return "", nil // 该工序未配置装配物料 → 不校验
|
||
}
|
||
// 已绑定明细:按 物料编码 → 绑定值集合
|
||
binds, _ := client.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, client *ent.Client, sn string) (string, error) {
|
||
if _, _, err := s.workpieceProduct(ctx, client, sn); err != nil {
|
||
return "", err
|
||
}
|
||
// 工艺路线由 station_process(按今日派工)派生:遍历今日启用的全部工序编号,逐一校验装配物料齐套
|
||
codes, err := RouteProcessCodes(ctx, client, TodayStr(), "")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
missing := []string{}
|
||
for _, code := range codes {
|
||
msg, err := s.validateStationBinds(ctx, client, 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"`
|
||
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
|
||
}
|
||
missing, err := s.validateAllProcessBinds(ctx, s.ctx.EntClient, sn)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 物料组成树:递归展开成品零部件到叶子(不依赖 WMS),让追溯页能展示完整物料组成,
|
||
// 避免成品作零部件被引用时只显示上层成品而漏下层料、追溯断链。
|
||
var tree []BindNode
|
||
if _, wo, perr := s.workpieceProduct(ctx, s.ctx.EntClient, sn); perr == nil && wo != nil {
|
||
tree = s.expandBindBom(ctx, s.ctx.EntClient, wo.ProductCode, "", 1, 0, map[string]bool{})
|
||
}
|
||
return &BindTraceVO{Binds: binds, Complete: missing == "", Missing: missing, BomTree: tree}, nil
|
||
}
|
||
|
||
// expandBindBom 递归展开某产品的物料组成树(成品/装配件若存在下级 BOM 则继续展开,叶子终止)。
|
||
// 不依赖 WMS:用 BomItem 是否存在 productCode 记录判断是否有下级 BOM,避免 WMS 不可达时不展开。
|
||
// 防环:同一条展开路径出现重复编码视为配置错误,跳过该分支。
|
||
func (s *Service) expandBindBom(ctx context.Context, client *ent.Client, productCode, bomName string, qty float64, depth int, path map[string]bool) []BindNode {
|
||
const maxDepth = 10
|
||
if depth > maxDepth || productCode == "" {
|
||
return nil
|
||
}
|
||
if path[productCode] {
|
||
s.ctx.EventLog.Write(ctx, "bom.expand.cycle", "", "", "product_bom", productCode,
|
||
"产品物料清单存在循环引用,已跳过", map[string]any{"depth": depth})
|
||
return nil
|
||
}
|
||
items, err := client.BomItem.Query().
|
||
Where(bomitem.ProductCode(productCode), bomitem.BomName(bomNameOrDefault(bomName))).All(ctx)
|
||
if err != nil || len(items) == 0 {
|
||
return nil
|
||
}
|
||
path[productCode] = true
|
||
defer delete(path, productCode)
|
||
out := make([]BindNode, 0, len(items))
|
||
for _, it := range items {
|
||
if it.MaterialCode == "" {
|
||
continue
|
||
}
|
||
node := BindNode{
|
||
MaterialCode: it.MaterialCode, MaterialName: it.MaterialName, Spec: it.Spec,
|
||
Unit: it.Unit, ManageMode: it.ManageMode, Qty: it.UnitQty * qty, Level: depth,
|
||
}
|
||
// 该物料自身是否还有下级 BOM(成品/装配件):递归展开其子件
|
||
if has, _ := client.BomItem.Query().Where(bomitem.ProductCode(it.MaterialCode)).Exist(ctx); has {
|
||
node.Children = s.expandBindBom(ctx, client, it.MaterialCode, bomName, it.UnitQty*qty, depth+1, path)
|
||
}
|
||
out = append(out, node)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 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) {
|
||
_, wo, err := s.workpieceProduct(ctx, s.ctx.EntClient, sn)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
targets, err := s.stationBindTargets(ctx, s.ctx.EntClient, wo.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, s.ctx.EntClient, 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
|
||
}
|