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 }