Files
bj_power/bj_power_mes/internal/logic/workpiece.go
T

506 lines
18 KiB
Go
Raw Normal View History

2026-08-28 15:06:01 +08:00
package logic
import (
"context"
"errors"
"strconv"
2026-09-15 16:37:39 +08:00
"strings"
2026-08-28 15:06:01 +08:00
"time"
"bj_power_mes/ent"
"bj_power_mes/ent/associationtrace"
2026-09-15 16:37:39 +08:00
"bj_power_mes/ent/processstep"
"bj_power_mes/ent/station"
2026-08-28 15:06:01 +08:00
"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)
2026-08-28 15:06:01 +08:00
}
// 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"`
2026-09-15 16:37:39 +08:00
// Checked:该步骤是否已完成「检测确认」。needCheck=true 的步骤必须为 true,服务端强校验,防止绕过前端。
Checked bool `json:"checked"`
2026-08-28 15:06:01 +08:00
}
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
2026-08-28 15:06:01 +08:00
}
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)
2026-08-28 15:06:01 +08:00
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()
2026-08-28 15:06:01 +08:00
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)
2026-08-28 15:06:01 +08:00
}
// ReportProcess 工位报工:先应用装机绑定并做强校验(齐套才放行),再写报工实绩 + 步骤考核。
// 绑料是"落到工艺流程"的强约束:产品 BOM 中标记为本工序(processCode)的物料必须绑齐,
// 电气件按单台用量逐颗SN、结构件至少一个批次号;缺料直接拒绝报工并返回缺料清单。
2026-08-28 15:06:01 +08:00
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)
}
2026-09-15 16:37:39 +08:00
// 阶段2:检测确认(needCheck)强校验——服务端为准,前端勾选只是辅助提示。
// 本工位绑定工艺流程中标记“需要检测确认”的步骤,报工数据里必须逐条带 checked=true,否则拒绝报工。
if err := s.validateNeedCheck(ctx, client, req); err != nil {
2026-09-15 16:37:39 +08:00
return err
}
2026-08-28 15:06:01 +08:00
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().
2026-08-28 15:06:01 +08:00
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)
2026-08-28 15:06:01 +08:00
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 {
2026-08-28 15:06:01 +08:00
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 {
2026-08-28 15:06:01 +08:00
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
2026-08-28 15:06:01 +08:00
s.ctx.EventLog.Write(ctx, "workpiece.process.report", wp.OrderNo, operator, "workpiece", req.Sn,
"工位报工", map[string]any{"processCode": req.ProcessCode, "result": result})
s.notifyDashboard()
2026-08-28 15:06:01 +08:00
return nil
}
2026-09-15 16:37:39 +08:00
// validateNeedCheck 检测确认(needCheck)服务端强校验。
// 规则:本工位绑定的工艺流程中,凡标记 needCheck=true 的工艺步骤,
// 报工请求里必须存在对应 stepId 且 checked=true 的步骤数据;缺失则拒绝报工。
// 工位未绑定流程、或流程内没有 needCheck 步骤时不拦截(与现场配置保持一致)。
func (s *Service) validateNeedCheck(ctx context.Context, client *ent.Client, req ReportProcessReq) error {
2026-09-15 16:37:39 +08:00
if req.StationNo <= 0 {
return nil
}
st, err := client.Station.Query().Where(station.StationNo(req.StationNo)).First(ctx)
2026-09-15 16:37:39 +08:00
if err != nil || st.FlowId <= 0 {
return nil
}
steps, err := client.ProcessStep.Query().
2026-09-15 16:37:39 +08:00
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
}
2026-08-28 15:06:01 +08:00
// writeStepData 写一条步骤考核数据,按标准自动判定是否合格
func (s *Service) writeStepData(ctx context.Context, client *ent.Client, sn string, processId int, st StepDataReq, operator string) (bool, error) {
2026-08-28 15:06:01 +08:00
ok := true
crits, err := client.StepCriterion.Query().Where(stepcriterion.StepId(st.StepId)).All(ctx)
if err != nil {
return false, err
}
2026-08-28 15:06:01 +08:00
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().
2026-08-28 15:06:01 +08:00
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
2026-08-28 15:06:01 +08:00
}
// DoneWorkpiece 完工:先兜底校验全部工序装配物料齐套(工艺流程强校验),再登记成品并生成关联追溯
2026-08-28 15:06:01 +08:00
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)
}
2026-08-28 15:06:01 +08:00
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)
2026-08-28 15:06:01 +08:00
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()
2026-08-28 15:06:01 +08:00
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})
}
2026-08-28 15:06:01 +08:00
// 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)
2026-08-28 15:06:01 +08:00
return map[string]any{
"workpiece": wp,
"processTimeline": procList,
"stepData": steps,
"associationTrace": assoc,
"torqueRecords": torque,
"bindTrace": bindVO,
2026-08-28 15:06:01 +08:00
}, 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
}