Files
bj_power/bj_power_mes/internal/logic/workpiece.go
T
SunYF 1cc93795ce feat(mes): 添加定时同步可生产数量到WMS及BOM项相关标准字段
- 在main.go中添加定时任务每10分钟同步可生产数量到WMS系统
- 为BomItem实体添加relatedStandard字段及相关CRUD方法
- 为InspectionRecord实体添加reportNo、materialCode、materialName等字段
- 更新ent schema确保新字段的验证和默认值设置
- 添加必要的数据库迁移和字段映射逻辑
2026-09-17 12:49:07 +08:00

506 lines
18 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"
"strconv"
"strings"
"time"
"bj_power_mes/ent"
"bj_power_mes/ent/associationtrace"
"bj_power_mes/ent/processstep"
"bj_power_mes/ent/station"
"bj_power_mes/ent/stepcriterion"
"bj_power_mes/ent/stepdata"
"bj_power_mes/ent/torquerecord"
"bj_power_mes/ent/workorder"
"bj_power_mes/ent/workpiece"
"bj_power_mes/ent/workpieceprocess"
)
func itoa(n int) string { return strconv.Itoa(n) }
func processName(code int) string {
names := map[int]string{1: "装配一", 2: "装配二", 3: "装配三", 4: "装配四", 5: "装配五", 6: "装配六", 7: "装配七", 8: "装配八", 9: "装配九", 10: "装配十", 11: "装配十一", 12: "装配十二"}
if n, ok := names[code]; ok {
return n
}
return "工位" + itoa(code)
}
// evalLogic 依据考核逻辑判定
func evalLogic(c *ent.StepCriterion, v float64) bool {
switch c.Logic {
case "GE":
return v >= c.Target
case "LE":
return v <= c.Target
case "GT":
return v > c.Target
case "LT":
return v < c.Target
case "RANGE":
l, r := true, true
if c.Min != nil {
l = v >= *c.Min
}
if c.Max != nil {
r = v <= *c.Max
}
return l && r
case "EQUAL":
return v == c.Target
default:
return true
}
}
type StepDataReq struct {
StepId int `json:"stepId"`
Name string `json:"name"`
Value float64 `json:"value"`
Text string `json:"text"`
// Checked:该步骤是否已完成「检测确认」。needCheck=true 的步骤必须为 true,服务端强校验,防止绕过前端。
Checked bool `json:"checked"`
}
type OnlineReq struct {
Sn string `json:"sn"`
OrderNo string `json:"orderNo"`
WorkOrderId int `json:"workOrderId"`
ProcessSeq string `json:"processSeq"`
}
// OnlineWorkpiece 工件进线登记
func (s *Service) OnlineWorkpiece(ctx context.Context, req OnlineReq, operator string) error {
if req.Sn == "" {
return errors.New("sn 不能为空")
}
seq := req.ProcessSeq
if seq == "" {
seq = FullProcessSeq
}
if req.WorkOrderId == 0 {
if wo, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNo(req.OrderNo)).First(ctx); err == nil {
req.WorkOrderId = wo.ID
if req.ProcessSeq == "" && wo.ProcessSeq != "" {
seq = wo.ProcessSeq
}
}
}
seq = NormalizeProcessSeq(seq)
err := s.ctx.EntClient.Workpiece.Create().
SetSn(req.Sn).SetOrderNo(req.OrderNo).SetWorkOrderId(req.WorkOrderId).
SetProcessSeq(seq).SetStatus("ONLINE").SetOnlineAt(time.Now()).Exec(ctx)
if err != nil {
return err
}
s.ctx.EventLog.Write(ctx, "workpiece.online", req.OrderNo, operator, "workpiece", req.Sn, "工件进线登记", nil)
s.notifyDashboard()
return nil
}
type ReportProcessReq struct {
Sn string `json:"sn"`
ProcessCode int `json:"processCode"`
StationNo int `json:"stationNo"`
Steps []StepDataReq `json:"steps"`
Binds []BindItemReq `json:"binds"` // 装机绑定(本工序装配物料:结构件批次/电气件SN),随报工原子提交
// 作业时长采集(方案 M / P0-3):工位终端在「上料到本工位」时采 startedAt、点「报工」时采 endedAt
// 两端时间戳同源于终端时钟(ISO8601),离线重传也不失真;缺失则回退服务端当前时刻(时长=0)。
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt"`
}
// parseClientTime 解析工位终端上报的 ISO8601 时间戳(兼容带/不带毫秒、Z/时区偏移)。
func parseClientTime(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, errors.New("empty time")
}
layouts := []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05", "2006-01-02 15:04:05"}
for _, l := range layouts {
if t, err := time.Parse(l, s); err == nil {
return t, nil
}
}
return time.Time{}, errors.New("unrecognized time format: " + s)
}
// 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 必填")
}
wp, err := s.ctx.EntClient.Workpiece.Query().Where(workpiece.Sn(req.Sn)).First(ctx)
if err != nil {
return errors.New("工件未进线登记")
}
// 报工是"装机绑定→校验齐套→写实绩→写步骤考核→回写结果/工件状态"的多步写,必须整体事务化(方案 U3):
// 任一步失败全部回滚,杜绝"实绩已建但工件状态没推进/结果没落"的半成品脏数据;
// 校验(齐套/needCheck)也走同一 tx client,才能读到本事务内刚写入的绑定。
tx, err := s.ctx.EntClient.Tx(ctx)
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
client := tx.Client()
// 阶段1:装机绑定(随报工提交)→ 落库后强校验齐套
stationNo := ""
if req.StationNo > 0 {
stationNo = itoa(req.StationNo)
}
if _, err := s.applyBinds(ctx, client, req.Sn, wp.OrderNo, req.ProcessCode, stationNo, operator, req.Binds); err != nil {
return err
}
if miss, err := s.validateStationBinds(ctx, client, req.Sn, req.ProcessCode); err != nil {
return err
} else if miss != "" {
return errors.New(miss)
}
// 阶段2:检测确认(needCheck)强校验——服务端为准,前端勾选只是辅助提示。
// 本工位绑定工艺流程中标记“需要检测确认”的步骤,报工数据里必须逐条带 checked=true,否则拒绝报工。
if err := s.validateNeedCheck(ctx, client, req); err != nil {
return err
}
now := time.Now()
// 作业时长(方案 M / P0-3):startedAt=进工位(上料)时刻、endedAt=报工时刻,均由工位终端采集;
// 缺失或非法则回退到服务端当前时刻(durationSec=0),时钟回拨导致负数则时长归零。
startedAt, endedAt := now, now
if t, e := parseClientTime(req.StartedAt); e == nil {
startedAt = t
}
if t, e := parseClientTime(req.EndedAt); e == nil {
endedAt = t
}
durationSec := int(endedAt.Sub(startedAt).Seconds())
if durationSec < 0 {
durationSec = 0
endedAt = startedAt
}
proc, err := client.WorkpieceProcess.Create().
SetSn(req.Sn).SetProcessCode(req.ProcessCode).
SetStationNo(itoa(req.StationNo)).SetOrderNo(wp.OrderNo).
SetProcessName(processName(req.ProcessCode)).
SetStatus("DONE").SetOperator(operator).SetResult("OK").
SetStartedAt(startedAt).SetEndedAt(endedAt).SetDurationSec(durationSec).Save(ctx)
if err != nil {
return err
}
allOK := true
for _, st := range req.Steps {
ok, e := s.writeStepData(ctx, client, req.Sn, proc.ID, st, operator)
if e != nil {
return e
}
if !ok {
allOK = false
}
}
if bad, e := client.StepData.Query().
Where(stepdata.ProcessId(proc.ID), stepdata.IsOK(false)).Exist(ctx); e != nil {
return e
} else if bad {
allOK = false
}
result := "OK"
if !allOK {
result = "NG"
}
if _, err := client.WorkpieceProcess.UpdateOneID(proc.ID).SetResult(result).Save(ctx); err != nil {
return err
}
if _, err := client.Workpiece.UpdateOneID(wp.ID).
SetCurrentProcess(req.ProcessCode).SetStatus("PROCESSING").Save(ctx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
committed = true
s.ctx.EventLog.Write(ctx, "workpiece.process.report", wp.OrderNo, operator, "workpiece", req.Sn,
"工位报工", map[string]any{"processCode": req.ProcessCode, "result": result})
s.notifyDashboard()
return nil
}
// validateNeedCheck 检测确认(needCheck)服务端强校验。
// 规则:本工位绑定的工艺流程中,凡标记 needCheck=true 的工艺步骤,
// 报工请求里必须存在对应 stepId 且 checked=true 的步骤数据;缺失则拒绝报工。
// 工位未绑定流程、或流程内没有 needCheck 步骤时不拦截(与现场配置保持一致)。
func (s *Service) validateNeedCheck(ctx context.Context, client *ent.Client, req ReportProcessReq) error {
if req.StationNo <= 0 {
return nil
}
st, err := client.Station.Query().Where(station.StationNo(req.StationNo)).First(ctx)
if err != nil || st.FlowId <= 0 {
return nil
}
steps, err := client.ProcessStep.Query().
Where(processstep.FlowId(st.FlowId), processstep.NeedCheck(true)).All(ctx)
if err != nil || len(steps) == 0 {
return nil
}
checked := map[int]bool{}
for _, sd := range req.Steps {
if sd.Checked {
checked[sd.StepId] = true
}
}
missing := make([]string, 0, len(steps))
for _, ps := range steps {
if !checked[ps.ID] {
name := ps.Name
if name == "" {
name = "步骤" + itoa(ps.ID)
}
missing = append(missing, name)
}
}
if len(missing) > 0 {
return errors.New("以下步骤需先完成检测确认后方可报工:" + strings.Join(missing, "、"))
}
return nil
}
// writeStepData 写一条步骤考核数据,按标准自动判定是否合格
func (s *Service) writeStepData(ctx context.Context, client *ent.Client, sn string, processId int, st StepDataReq, operator string) (bool, error) {
ok := true
crits, err := client.StepCriterion.Query().Where(stepcriterion.StepId(st.StepId)).All(ctx)
if err != nil {
return false, err
}
for _, c := range crits {
if !evalLogic(c, st.Value) {
ok = false
}
}
var cname, clogic string
cid := 0
if len(crits) > 0 {
cname = crits[0].Name
clogic = crits[0].Logic
cid = crits[0].ID
}
_, err = client.StepData.Create().
SetSn(sn).SetProcessId(processId).SetStepId(st.StepId).
SetStepName(st.Name).SetCriterionId(cid).SetCriterionName(cname).
SetCriterionLogic(clogic).SetValue(st.Value).SetValueText(st.Text).
SetIsOK(ok).SetOperator(operator).Save(ctx)
return ok, err
}
// DoneReq 完工 + 成品关联追溯
type DoneReq struct {
Sn string `json:"sn"`
BatchItems []string `json:"batchItems"` // 结构件批次号
SerialItems []string `json:"serialItems"` // 电气件SN
}
// 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, s.ctx.EntClient, 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)
existID, _ := s.ctx.EntClient.AssociationTrace.Query().
Where(associationtrace.FinishedSn(req.Sn)).FirstID(ctx)
if existID > 0 {
_, _ = s.ctx.EntClient.AssociationTrace.UpdateOneID(existID).
SetBatchItems(req.BatchItems).SetSerialItems(req.SerialItems).SetOperator(operator).Save(ctx)
} else {
_, err = s.ctx.EntClient.AssociationTrace.Create().
SetFinishedSn(req.Sn).SetOrderNo(wp.OrderNo).SetProcessCode(wp.ProcessSeq).
SetBatchItems(req.BatchItems).SetSerialItems(req.SerialItems).
SetOperator(operator).Save(ctx)
if err != nil {
return err
}
}
s.bumpWorkOrderProgress(ctx, wp.OrderNo, req.Sn)
s.bumpDailyPlanCompleted(ctx, wp.OrderNo, now)
s.ctx.EventLog.Write(ctx, "workpiece.done", wp.OrderNo, operator, "workpiece", req.Sn, "完工+关联追溯", map[string]any{"batchItems": req.BatchItems, "serialItems": req.SerialItems})
// 成品完工自动回流 WMS(统一库存主表 category=3):
// 成品 SN = 工件 SN(进线登记即系统发号,全程唯一,保证对号可追溯)。
// WMS 不可达/校验失败时降级:记 eventlog 不阻塞完工,与备料 WMS 校验降级策略同模式。
s.retainFinishedToWms(ctx, wp, operator)
s.notifyDashboard()
return nil
}
// retainFinishedToWms 完工品回流 WMS 库存。失败仅记日志(wms.finished.retain_failed),
// 不回滚完工状态——仓管可在 WMS 侧人工补录,避免 WMS 抖动卡死产线。
func (s *Service) retainFinishedToWms(ctx context.Context, wp *ent.Workpiece, operator string) {
wo, err := s.ctx.EntClient.WorkOrder.Get(ctx, wp.WorkOrderId)
if err != nil || wo == nil {
s.ctx.EventLog.Write(ctx, "wms.finished.retain_failed", wp.OrderNo, operator, "workpiece", wp.Sn,
"成品回流WMS失败:工单不存在", map[string]any{"error": "work order not found"})
return
}
if wmsErr := s.ctx.Wms.FinishedInbound(ctx, wo.ProductCode, wo.ProductName, wp.Sn, wp.OrderNo, operator); wmsErr != nil {
s.ctx.EventLog.Write(ctx, "wms.finished.retain_failed", wp.OrderNo, operator, "workpiece", wp.Sn,
"成品回流WMS失败(库存未入,请人工补录)", map[string]any{"error": wmsErr.Error(), "productCode": wo.ProductCode})
return
}
s.ctx.EventLog.Write(ctx, "wms.finished.retain", wp.OrderNo, operator, "workpiece", wp.Sn,
"成品完工已回流WMS库存", map[string]any{"productCode": wo.ProductCode})
}
// Trace 追溯查询(SN 维度):工序时间线/操作人/步骤数据/物料批次SN/拧紧数据
func (s *Service) Trace(ctx context.Context, sn string) (map[string]any, error) {
wp, err := s.ctx.EntClient.Workpiece.Query().Where(workpiece.Sn(sn)).First(ctx)
if err != nil {
return nil, errors.New("该SN无追溯数据")
}
procList, _ := s.ctx.EntClient.WorkpieceProcess.Query().
Where(workpieceprocess.Sn(sn)).Order(ent.Asc(workpieceprocess.FieldProcessCode)).All(ctx)
steps, _ := s.ctx.EntClient.StepData.Query().
Where(stepdata.Sn(sn)).Order(ent.Desc(stepdata.FieldID)).All(ctx)
assoc, _ := s.ctx.EntClient.AssociationTrace.Query().
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,
"processTimeline": procList,
"stepData": steps,
"associationTrace": assoc,
"torqueRecords": torque,
"bindTrace": bindVO,
}, nil
}
func (s *Service) ListWorkpieces(ctx context.Context, sn, orderNo string) ([]*ent.Workpiece, error) {
q := s.ctx.EntClient.Workpiece.Query()
if sn != "" {
q = q.Where(workpiece.SnContains(sn))
}
if orderNo != "" {
q = q.Where(workpiece.OrderNo(orderNo))
}
return q.Order(ent.Desc(workpiece.FieldID)).Limit(500).All(ctx)
}
// InProcessItem 在制品(已进线未完工)视图项。跨天工件继续停留,日排产不重复排。
type InProcessItem struct {
Sn string `json:"sn"`
OrderNo string `json:"orderNo"`
ProductCode string `json:"productCode"`
ProductName string `json:"productName"`
CurrentStation int `json:"currentStation"` // 0=上线位, 13=下线位, 其余为物理工位
CurrentStationName string `json:"currentStationName"` // 工位显示名(上线位/下线位/装配X)
Status string `json:"status"`
OnlineAt string `json:"onlineAt"`
DwellSec int64 `json:"dwellSec"` // 停留时长(秒),自进线起算
DoneSteps int `json:"doneSteps"`
TotalSteps int `json:"totalSteps"`
}
// WorkpiecesInProcess 列出停在各工位、尚未完工的在制品(ONLINE/PROCESSING/REPAIR)。
// stationNo<=0 返回全部;stationNo>0 仅返回当前停在该工位(0=上线位, 13=下线位)的工件。
// 已进线未完工的工件跨天继续,日排产仅排「未开工数量」,不会重复排产。
func (s *Service) WorkpiecesInProcess(ctx context.Context, stationNo int) ([]InProcessItem, error) {
list, err := s.ctx.EntClient.Workpiece.Query().
Where(workpiece.Or(
workpiece.Status("ONLINE"),
workpiece.Status("PROCESSING"),
workpiece.Status("REPAIR"),
)).
Order(ent.Desc(workpiece.FieldOnlineAt)).All(ctx)
if err != nil {
return nil, err
}
out := make([]InProcessItem, 0, len(list))
for _, wp := range list {
seq := ParseProcessSeq(wp.ProcessSeq)
if len(seq) == 0 {
seq = ParseProcessSeq(FullProcessSeq)
}
maxStation := 0
for _, c := range seq {
if c > maxStation {
maxStation = c
}
}
// 推算当前物理工位:ONLINE 在 上线位(0)PROCESSING 且已到末端 → 下线位(13)
cur := 0
if wp.Status == "PROCESSING" || wp.Status == "REPAIR" {
cp := wp.CurrentProcess
if cp <= 0 {
cp = maxStation
}
if cp >= maxStation && wp.Status == "PROCESSING" {
cur = 13
} else {
cur = cp
}
}
if stationNo > 0 && cur != stationNo {
continue
}
dwell := int64(0)
var onlineAt string
if wp.OnlineAt != nil {
dwell = int64(time.Since(*wp.OnlineAt).Seconds())
onlineAt = wp.OnlineAt.Format("2006-01-02 15:04:05")
}
done := 0
if procs, e := s.ctx.EntClient.WorkpieceProcess.Query().
Where(workpieceprocess.Sn(wp.Sn), workpieceprocess.Status("DONE")).Count(ctx); e == nil {
done = procs
}
code, name := "", ""
if wp.WorkOrderId > 0 {
if wo, e := s.ctx.EntClient.WorkOrder.Get(ctx, wp.WorkOrderId); e == nil {
code, name = wo.ProductCode, wo.ProductName
}
}
if code == "" && wp.OrderNo != "" {
if wo, e := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNo(wp.OrderNo)).First(ctx); e == nil {
code, name = wo.ProductCode, wo.ProductName
}
}
stName := stationDisplayName("", cur)
if cur == 0 {
stName = "上线位(虚拟)"
} else if cur == 13 {
stName = "下线位(虚拟)"
}
out = append(out, InProcessItem{
Sn: wp.Sn,
OrderNo: wp.OrderNo,
ProductCode: code,
ProductName: name,
CurrentStation: cur,
CurrentStationName: stName,
Status: wp.Status,
OnlineAt: onlineAt,
DwellSec: dwell,
DoneSteps: done,
TotalSteps: len(seq),
})
}
return out, nil
}