2026-08-28 15:06:01 +08:00
|
|
|
package logic
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-09-04 14:18:53 +08:00
|
|
|
"sort"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
2026-08-28 15:06:01 +08:00
|
|
|
"time"
|
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
"bj_power_mes/ent"
|
|
|
|
|
"bj_power_mes/ent/dailyplan"
|
|
|
|
|
"bj_power_mes/ent/materialrequest"
|
2026-08-28 15:06:01 +08:00
|
|
|
"bj_power_mes/ent/plcsendlog"
|
|
|
|
|
"bj_power_mes/ent/torquerecord"
|
|
|
|
|
"bj_power_mes/ent/workorder"
|
|
|
|
|
"bj_power_mes/ent/workpiece"
|
|
|
|
|
"bj_power_mes/ent/workpieceprocess"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const dashboardTTL = 60 // 秒,看板缓存
|
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
// 工位活动判定窗口:窗口内有工序实绩视为「运行中」,否则为空闲。
|
|
|
|
|
// 旧实现用历史累计 done>0 判定,导致工位永远显示「运行中」,永不离线。
|
|
|
|
|
const stationActiveWindow = 5 * time.Minute
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// 主入口:看板全量快照
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
// DashboardSnapshot 一次返回全部看板数据。
|
|
|
|
|
// 各子模块单独容错:某块查询失败只降级该块,不影响整屏(看板是展示系统,宁可缺一块也不要白屏)。
|
|
|
|
|
func (s *Service) DashboardSnapshot(ctx context.Context) (*DashboardSnapshot, error) {
|
|
|
|
|
return cachedTyped(ctx, s, "dashboard:snapshot", dashboardTTL, func() (*DashboardSnapshot, error) {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
today := dayStart(now)
|
|
|
|
|
snap := &DashboardSnapshot{UpdatedAt: fmtDashboardTime(now)}
|
|
|
|
|
|
|
|
|
|
snap.Production = s.buildProduction(ctx, today)
|
|
|
|
|
snap.Quality = s.buildQuality(ctx, today)
|
|
|
|
|
snap.Trace = s.buildTrace(ctx)
|
|
|
|
|
snap.Warehouse = s.buildWarehouse(ctx)
|
|
|
|
|
snap.Equipment = s.buildEquipment(ctx, today)
|
|
|
|
|
snap.Agv = s.buildAgv(ctx)
|
|
|
|
|
snap.Performance = s.buildPerformance(ctx, today)
|
|
|
|
|
snap.Alarms = s.buildAlarms(ctx, today)
|
|
|
|
|
snap.Trends = s.buildTrends(ctx)
|
|
|
|
|
snap.Events = s.buildEvents(ctx)
|
|
|
|
|
return snap, nil
|
2026-08-28 15:06:01 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
// ============================================================
|
|
|
|
|
// 屏1/屏2:生产统计
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
func (s *Service) buildProduction(ctx context.Context, today time.Time) ProductionStats {
|
|
|
|
|
out := ProductionStats{}
|
|
|
|
|
tomorrow := today.Add(24 * time.Hour)
|
|
|
|
|
|
|
|
|
|
// 当日完工产量:以 workpiece.doneAt 落在本日为准
|
|
|
|
|
if n, err := s.ctx.EntClient.Workpiece.Query().
|
|
|
|
|
Where(workpiece.DoneAtGTE(today), workpiece.DoneAtLT(tomorrow)).Count(ctx); err == nil {
|
|
|
|
|
out.OutputToday = n
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 当日计划产量:日排产表当日 planQty 合计
|
|
|
|
|
out.TargetToday = s.sumDailyPlanQty(ctx, today.Format("2006-01-02"))
|
|
|
|
|
out.AchieveRate = safeRate(float64(out.OutputToday), float64(out.TargetToday))
|
|
|
|
|
|
|
|
|
|
// 在制:已进线未完工
|
|
|
|
|
if n, err := s.ctx.EntClient.Workpiece.Query().
|
|
|
|
|
Where(workpiece.DoneAtIsNil()).Count(ctx); err == nil {
|
|
|
|
|
out.InLine = n
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 当日工序实绩合格率(一次合格率口径:工序判定而非拧紧单次)
|
|
|
|
|
if steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
|
|
|
|
|
Where(workpieceprocess.EndedAtGTE(today), workpieceprocess.EndedAtLT(tomorrow)).
|
|
|
|
|
All(ctx); err == nil {
|
|
|
|
|
for _, st := range steps {
|
|
|
|
|
if strings.EqualFold(st.Result, "NG") {
|
|
|
|
|
out.QualityNg++
|
|
|
|
|
} else {
|
|
|
|
|
out.QualityOk++
|
2026-08-28 15:06:01 +08:00
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out.QualifiedRate = safeRate(float64(out.QualityOk), float64(out.QualityOk+out.QualityNg))
|
|
|
|
|
|
|
|
|
|
// 当日拧紧总次数
|
|
|
|
|
if n, err := s.ctx.EntClient.TorqueRecord.Query().
|
|
|
|
|
Where(torquerecord.TimeGTE(today), torquerecord.TimeLT(tomorrow)).Count(ctx); err == nil {
|
|
|
|
|
out.TorqueTotal = n
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out.WorkOrders = s.buildWorkOrderProgress(ctx, today)
|
|
|
|
|
if len(out.WorkOrders) > 0 {
|
|
|
|
|
cur := out.WorkOrders[0]
|
|
|
|
|
out.CurrentOrder = &cur
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 工位状态汇总由 equipment 填充(避免重复查询),此处先占位
|
|
|
|
|
return out
|
2026-08-28 15:06:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
func (s *Service) buildWorkOrderProgress(ctx context.Context, today time.Time) []WorkOrderProgress {
|
|
|
|
|
wos, err := s.ctx.EntClient.WorkOrder.Query().
|
|
|
|
|
Order(ent.Desc(workorder.FieldID)).Limit(20).All(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return []WorkOrderProgress{}
|
|
|
|
|
}
|
|
|
|
|
date := today.Format("2006-01-02")
|
|
|
|
|
list := make([]WorkOrderProgress, 0, len(wos))
|
|
|
|
|
for _, w := range wos {
|
|
|
|
|
item := WorkOrderProgress{
|
|
|
|
|
OrderNo: w.WorkOrderNo,
|
|
|
|
|
ProductName: w.ProductName,
|
|
|
|
|
TotalQty: w.Quantity,
|
|
|
|
|
DoneQty: w.FinishedNum,
|
|
|
|
|
NgQty: w.FailNum,
|
|
|
|
|
Progress: safeRate(float64(w.FinishedNum), float64(w.Quantity)),
|
|
|
|
|
Status: w.Status,
|
|
|
|
|
PlanStart: fmtDashboardTime(orZero(w.PlanStart)),
|
|
|
|
|
PlanEnd: fmtDashboardTime(orZero(w.PlanEnd)),
|
|
|
|
|
DailyPlanQty: s.sumDailyPlanQtyByOrder(ctx, date, w.WorkOrderNo),
|
|
|
|
|
DailyDoneQty: s.countWorkpieceDoneByOrder(ctx, today, w.WorkOrderNo),
|
|
|
|
|
}
|
|
|
|
|
item.CurrentProcess = "工序 " + strings.TrimSpace(w.ProcessSeq)
|
|
|
|
|
list = append(list, item)
|
|
|
|
|
}
|
|
|
|
|
// 进行中的工单排前面,同状态按 ID 倒序
|
|
|
|
|
sort.SliceStable(list, func(i, j int) bool {
|
|
|
|
|
return rankOrderStatus(list[i].Status) < rankOrderStatus(list[j].Status)
|
2026-08-28 15:06:01 +08:00
|
|
|
})
|
2026-09-04 14:18:53 +08:00
|
|
|
return list
|
2026-08-28 15:06:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
// ============================================================
|
|
|
|
|
// 屏3:质量分析
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
func (s *Service) buildQuality(ctx context.Context, today time.Time) QualityStats {
|
|
|
|
|
out := QualityStats{OkRate: 0}
|
|
|
|
|
tomorrow := today.Add(24 * time.Hour)
|
2026-08-28 15:06:01 +08:00
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
// 近 200 条拧紧记录:用于绘制扭矩分布散点 + 计算统计量
|
|
|
|
|
recs, err := s.ctx.EntClient.TorqueRecord.Query().
|
|
|
|
|
Where(torquerecord.TimeGTE(today), torquerecord.TimeLT(tomorrow)).
|
|
|
|
|
Order(ent.Asc(torquerecord.FieldTime)).Limit(200).All(ctx)
|
|
|
|
|
if err != nil || len(recs) == 0 {
|
|
|
|
|
out.TorqueScatter = []TorquePoint{}
|
|
|
|
|
out.NgByStation = []RankItem{}
|
|
|
|
|
out.NgByOperator = []RankItem{}
|
|
|
|
|
out.DailyRate = s.buildDailyOkRate(ctx, 30)
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stat := TorqueStat{Count: len(recs), MinStrain: recs[0].Strain, MaxStrain: recs[0].Strain}
|
|
|
|
|
sum := 0.0
|
|
|
|
|
scatter := make([]TorquePoint, 0, len(recs))
|
|
|
|
|
ngByStation := map[string]int{}
|
|
|
|
|
ngByOperator := map[string]int{}
|
|
|
|
|
for i, r := range recs {
|
|
|
|
|
if strings.EqualFold(r.Result, "NG") {
|
|
|
|
|
stat.NgCount++
|
|
|
|
|
ngByStation[r.StationNo]++
|
|
|
|
|
ngByOperator[r.Operator]++
|
|
|
|
|
} else {
|
|
|
|
|
stat.OkCount++
|
2026-08-28 15:06:01 +08:00
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
sum += r.Strain
|
|
|
|
|
if r.Strain < stat.MinStrain {
|
|
|
|
|
stat.MinStrain = r.Strain
|
2026-08-28 15:06:01 +08:00
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
if r.Strain > stat.MaxStrain {
|
|
|
|
|
stat.MaxStrain = r.Strain
|
2026-08-28 15:06:01 +08:00
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
scatter = append(scatter, TorquePoint{Seq: i + 1, Strain: r.Strain, Result: r.Result})
|
|
|
|
|
}
|
|
|
|
|
stat.AvgStrain = sum / float64(len(recs))
|
|
|
|
|
|
|
|
|
|
// 规格上下限取自工艺步骤指标(step_criterion),取不到时回退为 0(前端不画控制线)
|
|
|
|
|
if lo, hi, target, unit, ok := s.loadTorqueCriterion(ctx); ok {
|
|
|
|
|
stat.LowerLimit = lo
|
|
|
|
|
stat.UpperLimit = hi
|
|
|
|
|
stat.Target = target
|
|
|
|
|
stat.Unit = unit
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out.Torque = stat
|
|
|
|
|
out.OkRate = safeRate(float64(stat.OkCount), float64(stat.Count))
|
|
|
|
|
out.TorqueScatter = scatter
|
|
|
|
|
out.NgByStation = rankFromMap(ngByStation, "工位")
|
|
|
|
|
out.NgByOperator = rankFromMap(ngByOperator, "")
|
|
|
|
|
out.DailyRate = s.buildDailyOkRate(ctx, 30)
|
|
|
|
|
return out
|
2026-08-28 15:06:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
// buildDailyOkRate 近 n 日工序一次合格率(供合格率走势图)
|
|
|
|
|
func (s *Service) buildDailyOkRate(ctx context.Context, days int) []TrendPoint {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
points := make([]TrendPoint, 0, days)
|
|
|
|
|
for i := days - 1; i >= 0; i-- {
|
|
|
|
|
d := dayStart(now.AddDate(0, 0, -i))
|
|
|
|
|
next := d.Add(24 * time.Hour)
|
|
|
|
|
ok, ng := 0, 0
|
|
|
|
|
if steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
|
|
|
|
|
Where(workpieceprocess.EndedAtGTE(d), workpieceprocess.EndedAtLT(next)).All(ctx); err == nil {
|
|
|
|
|
for _, st := range steps {
|
|
|
|
|
if strings.EqualFold(st.Result, "NG") {
|
|
|
|
|
ng++
|
|
|
|
|
} else {
|
|
|
|
|
ok++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
points = append(points, TrendPoint{
|
|
|
|
|
Time: d.Format("01-02"),
|
|
|
|
|
Output: ok,
|
|
|
|
|
QualifiedRate: safeRate(float64(ok), float64(ok+ng)),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return points
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// 屏4:责任追溯
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
// buildTrace 返回最近完工工件(含完整工序时间线),供大屏自动轮播
|
|
|
|
|
func (s *Service) buildTrace(ctx context.Context) TraceStats {
|
|
|
|
|
wps, err := s.ctx.EntClient.Workpiece.Query().
|
|
|
|
|
Where(workpiece.DoneAtNotNil()).
|
|
|
|
|
Order(ent.Desc(workpiece.FieldDoneAt)).Limit(10).All(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return TraceStats{Recent: []TraceItem{}}
|
|
|
|
|
}
|
|
|
|
|
items := make([]TraceItem, 0, len(wps))
|
|
|
|
|
for _, w := range wps {
|
|
|
|
|
item := TraceItem{
|
|
|
|
|
Sn: w.Sn,
|
|
|
|
|
OrderNo: w.OrderNo,
|
|
|
|
|
Status: w.Status,
|
|
|
|
|
OnlineAt: fmtDashboardTime(orZero(w.OnlineAt)),
|
|
|
|
|
DoneAt: fmtDashboardTime(orZero(w.DoneAt)),
|
|
|
|
|
}
|
|
|
|
|
if w.DoneAt != nil && w.OnlineAt != nil {
|
|
|
|
|
item.DurationMin = w.DoneAt.Sub(*w.OnlineAt).Minutes()
|
|
|
|
|
}
|
|
|
|
|
item.Steps = s.loadTraceSteps(ctx, w.Sn)
|
|
|
|
|
item.StepCount = len(item.Steps)
|
|
|
|
|
for _, st := range item.Steps {
|
|
|
|
|
if strings.EqualFold(st.Result, "NG") {
|
|
|
|
|
item.NgCount++
|
|
|
|
|
} else {
|
|
|
|
|
item.OkCount++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
items = append(items, item)
|
|
|
|
|
}
|
|
|
|
|
return TraceStats{Recent: items}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// loadTraceSteps 单工件的工序时间线:工序 / 工位 / 操作人 / 时间 / 判定 / 扭矩
|
|
|
|
|
func (s *Service) loadTraceSteps(ctx context.Context, sn string) []TraceStep {
|
|
|
|
|
steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
|
|
|
|
|
Where(workpieceprocess.SnEQ(sn)).
|
|
|
|
|
Order(ent.Asc(workpieceprocess.FieldID)).All(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return []TraceStep{}
|
|
|
|
|
}
|
|
|
|
|
// 该工件的拧紧数据:按工位聚合,用于展示每道工序的实际扭矩
|
|
|
|
|
tq, _ := s.ctx.EntClient.TorqueRecord.Query().
|
|
|
|
|
Where(torquerecord.SnEQ(sn)).All(ctx)
|
|
|
|
|
type agg struct{ count, ng int; sum float64 }
|
|
|
|
|
byStation := map[string]*agg{}
|
|
|
|
|
for _, t := range tq {
|
|
|
|
|
a, ok := byStation[t.StationNo]
|
|
|
|
|
if !ok {
|
|
|
|
|
a = &agg{}
|
|
|
|
|
byStation[t.StationNo] = a
|
|
|
|
|
}
|
|
|
|
|
a.count++
|
|
|
|
|
a.sum += t.Strain
|
|
|
|
|
if strings.EqualFold(t.Result, "NG") {
|
|
|
|
|
a.ng++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out := make([]TraceStep, 0, len(steps))
|
|
|
|
|
for _, st := range steps {
|
|
|
|
|
ts := TraceStep{
|
|
|
|
|
ProcessCode: st.ProcessCode,
|
|
|
|
|
ProcessName: st.ProcessName,
|
|
|
|
|
StationNo: atoiSafe(st.StationNo),
|
|
|
|
|
Operator: st.Operator,
|
|
|
|
|
Result: st.Result,
|
|
|
|
|
StartedAt: fmtDashboardTime(orZero(st.StartedAt)),
|
|
|
|
|
EndedAt: fmtDashboardTime(orZero(st.EndedAt)),
|
|
|
|
|
}
|
|
|
|
|
if a, ok := byStation[st.StationNo]; ok && a.count > 0 {
|
|
|
|
|
ts.TorqueCount = a.count
|
|
|
|
|
ts.TorqueNg = a.ng
|
|
|
|
|
ts.AvgStrain = a.sum / float64(a.count)
|
|
|
|
|
}
|
|
|
|
|
out = append(out, ts)
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DashboardTrace 按需查询单个工件的追溯详情(供看板下钻,不进全量快照)
|
|
|
|
|
func (s *Service) DashboardTrace(ctx context.Context, sn string) (*TraceItem, error) {
|
|
|
|
|
w, err := s.ctx.EntClient.Workpiece.Query().Where(workpiece.SnEQ(sn)).Only(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
item := &TraceItem{
|
|
|
|
|
Sn: w.Sn,
|
|
|
|
|
OrderNo: w.OrderNo,
|
|
|
|
|
Status: w.Status,
|
|
|
|
|
OnlineAt: fmtDashboardTime(orZero(w.OnlineAt)),
|
|
|
|
|
DoneAt: fmtDashboardTime(orZero(w.DoneAt)),
|
|
|
|
|
Steps: s.loadTraceSteps(ctx, w.Sn),
|
|
|
|
|
}
|
|
|
|
|
item.StepCount = len(item.Steps)
|
|
|
|
|
for _, st := range item.Steps {
|
|
|
|
|
if strings.EqualFold(st.Result, "NG") {
|
|
|
|
|
item.NgCount++
|
|
|
|
|
} else {
|
|
|
|
|
item.OkCount++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return item, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// 屏5:仓储动态(数据源 WMS 8890)
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
func (s *Service) buildWarehouse(ctx context.Context) WarehouseStats {
|
|
|
|
|
out := WarehouseStats{Available: false, ZoneDetail: []ZoneStock{}}
|
|
|
|
|
if s.ctx.Wms == nil {
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
data, err := s.ctx.Wms.DisplayOverview(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
out.Available = true
|
|
|
|
|
out.TotalQty = toInt(data["totalQty"])
|
|
|
|
|
out.BatchCount = toInt(data["batchCount"])
|
|
|
|
|
out.SnInStock = toInt(data["snInStock"])
|
|
|
|
|
out.SemiInStock = toInt(data["semiInStock"])
|
|
|
|
|
out.PackageCount = toInt(data["packageCount"])
|
|
|
|
|
out.InToday = toInt(data["inToday"])
|
|
|
|
|
out.OutToday = toInt(data["outToday"])
|
|
|
|
|
out.LockedQty = toInt(data["lockedQty"])
|
|
|
|
|
out.MaterialTypes = toInt(data["materialTypes"])
|
|
|
|
|
if t, ok := data["time"].(string); ok {
|
|
|
|
|
out.UpdatedAt = t
|
|
|
|
|
}
|
|
|
|
|
if zm, ok := data["zoneDetail"].(map[string]any); ok {
|
|
|
|
|
for k, v := range zm {
|
|
|
|
|
parts := strings.SplitN(k, "/", 2)
|
|
|
|
|
z := ZoneStock{Qty: toInt(v)}
|
|
|
|
|
if len(parts) > 0 {
|
|
|
|
|
z.Zone = parts[0]
|
|
|
|
|
}
|
|
|
|
|
if len(parts) > 1 {
|
|
|
|
|
z.Quality = parts[1]
|
|
|
|
|
}
|
|
|
|
|
out.ZoneDetail = append(out.ZoneDetail, z)
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(out.ZoneDetail, func(i, j int) bool {
|
|
|
|
|
return out.ZoneDetail[i].Qty > out.ZoneDetail[j].Qty
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
out.ZoneDetail = []ZoneStock{}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// 屏1:工位实时状态(3D 场景与工位矩阵共用)
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
func (s *Service) buildEquipment(ctx context.Context, today time.Time) []StationState {
|
|
|
|
|
// 工位主数据
|
|
|
|
|
stMap := map[int]StationState{}
|
|
|
|
|
if sts, err := s.ctx.EntClient.Station.Query().All(ctx); err == nil {
|
|
|
|
|
for _, st := range sts {
|
|
|
|
|
stMap[st.StationNo] = StationState{
|
|
|
|
|
StationNo: st.StationNo,
|
|
|
|
|
Name: stationDisplayName(st.Name, st.StationNo),
|
|
|
|
|
Status: "idle",
|
|
|
|
|
}
|
|
|
|
|
if !strings.EqualFold(st.Status, "ENABLED") {
|
|
|
|
|
v := stMap[st.StationNo]
|
|
|
|
|
v.Status = "offline"
|
|
|
|
|
stMap[st.StationNo] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 工位主数据缺失时兜底生成 12 个工位
|
|
|
|
|
for i := 1; i <= 12; i++ {
|
|
|
|
|
if _, ok := stMap[i]; !ok {
|
|
|
|
|
stMap[i] = StationState{StationNo: i, Name: stationDisplayName("", i), Status: "idle"}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window := time.Now().Add(-stationActiveWindow)
|
|
|
|
|
// 活动窗口内的工序实绩:判定 running/alarm,并取当前作业人与工件
|
|
|
|
|
if recent, err := s.ctx.EntClient.WorkpieceProcess.Query().
|
|
|
|
|
Where(workpieceprocess.EndedAtGTE(window)).
|
|
|
|
|
Order(ent.Desc(workpieceprocess.FieldEndedAt)).All(ctx); err == nil {
|
|
|
|
|
for _, r := range recent {
|
|
|
|
|
no := atoiSafe(r.StationNo)
|
|
|
|
|
v, ok := stMap[no]
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if v.Status == "offline" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if v.LastActiveAt == "" {
|
|
|
|
|
v.LastActiveAt = fmtDashboardTime(orZero(r.EndedAt))
|
|
|
|
|
v.CurrentSn = r.Sn
|
|
|
|
|
v.CurrentOperator = r.Operator
|
|
|
|
|
v.CurrentProcess = r.ProcessCode
|
|
|
|
|
}
|
|
|
|
|
if v.Status != "alarm" {
|
|
|
|
|
if strings.EqualFold(r.Result, "NG") {
|
|
|
|
|
v.Status = "alarm"
|
|
|
|
|
} else {
|
|
|
|
|
v.Status = "running"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
stMap[no] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 当日累计:每个工位的完成数与 NG 数
|
|
|
|
|
if steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
|
|
|
|
|
Where(workpieceprocess.EndedAtGTE(today)).All(ctx); err == nil {
|
|
|
|
|
for _, r := range steps {
|
|
|
|
|
no := atoiSafe(r.StationNo)
|
|
|
|
|
v, ok := stMap[no]
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
v.DoneToday++
|
|
|
|
|
if strings.EqualFold(r.Result, "NG") {
|
|
|
|
|
v.NgToday++
|
|
|
|
|
}
|
|
|
|
|
stMap[no] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out := make([]StationState, 0, len(stMap))
|
|
|
|
|
for _, v := range stMap {
|
|
|
|
|
out = append(out, v)
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(out, func(i, j int) bool { return out[i].StationNo < out[j].StationNo })
|
|
|
|
|
return out
|
2026-08-28 15:06:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
// ============================================================
|
|
|
|
|
// 3D 场景:AGV
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
// buildAgv AGV 状态。
|
|
|
|
|
//
|
|
|
|
|
// RCS-2000 尚未对接,当前 Source="mock":任务取自真实备料单(material_request.targetDock),
|
|
|
|
|
// 即「哪台 AGV 在给哪个工位送什么料」是真实业务事件,仅坐标/电量/进度为模拟值。
|
|
|
|
|
// 接入 RCS 后改为调用 /api/robot/controller/robot/query 填充真实坐标即可,前端无需改动。
|
|
|
|
|
func (s *Service) buildAgv(ctx context.Context) AgvStats {
|
|
|
|
|
out := AgvStats{Source: "mock", Robots: []AgvRobot{}}
|
|
|
|
|
reqs, err := s.ctx.EntClient.MaterialRequest.Query().
|
|
|
|
|
Where(materialrequest.TargetDockNEQ("")).
|
|
|
|
|
Order(ent.Desc(materialrequest.FieldID)).Limit(2).All(ctx)
|
|
|
|
|
if err != nil || len(reqs) == 0 {
|
|
|
|
|
// 无任务时给出两台待命 AGV,3D 场景不至于空场
|
|
|
|
|
out.Robots = []AgvRobot{
|
|
|
|
|
{Code: "AGV-01", Status: "idle", Battery: 88, FromDock: "DOCK21", ToDock: "", Progress: 0},
|
|
|
|
|
{Code: "AGV-02", Status: "idle", Battery: 76, FromDock: "DOCK21", ToDock: "", Progress: 0},
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
for i, r := range reqs {
|
|
|
|
|
carrier := r.MaterialName
|
|
|
|
|
if carrier == "" {
|
|
|
|
|
carrier = r.MaterialCode
|
|
|
|
|
}
|
|
|
|
|
out.Robots = append(out.Robots, AgvRobot{
|
|
|
|
|
Code: "AGV-" + pad2(i+1),
|
|
|
|
|
Status: agvStatusFromRequest(r.Status),
|
|
|
|
|
Battery: 90 - i*12,
|
|
|
|
|
FromDock: "DOCK21",
|
|
|
|
|
ToDock: r.TargetDock,
|
|
|
|
|
TaskNo: r.RequestNo,
|
|
|
|
|
Carrier: carrier,
|
|
|
|
|
// 进度按任务创建时间线性推进,60 秒走完全程;仅用于 3D 动效
|
|
|
|
|
Progress: mockAgvProgress(r.CreatedAt),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// agvStatusFromRequest 备料单状态 → AGV 状态(3D 动效据此播放不同动画)
|
|
|
|
|
func agvStatusFromRequest(status string) string {
|
|
|
|
|
switch strings.ToUpper(status) {
|
|
|
|
|
case "LOCKED":
|
|
|
|
|
return "loading"
|
|
|
|
|
case "DELIVERING":
|
|
|
|
|
return "moving"
|
|
|
|
|
case "DONE":
|
|
|
|
|
return "idle"
|
|
|
|
|
default:
|
|
|
|
|
return "waiting"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// 屏6:人员绩效
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
func (s *Service) buildPerformance(ctx context.Context, today time.Time) []RankItem {
|
|
|
|
|
steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
|
|
|
|
|
Where(workpieceprocess.EndedAtGTE(today)).All(ctx)
|
2026-08-28 15:06:01 +08:00
|
|
|
if err != nil {
|
2026-09-04 14:18:53 +08:00
|
|
|
return []RankItem{}
|
|
|
|
|
}
|
|
|
|
|
type agg struct {
|
|
|
|
|
count, ok, ng int
|
|
|
|
|
stations map[int]bool
|
|
|
|
|
}
|
|
|
|
|
byOp := map[string]*agg{}
|
|
|
|
|
for _, st := range steps {
|
|
|
|
|
if st.Operator == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
a, ok := byOp[st.Operator]
|
|
|
|
|
if !ok {
|
|
|
|
|
a = &agg{stations: map[int]bool{}}
|
|
|
|
|
byOp[st.Operator] = a
|
|
|
|
|
}
|
|
|
|
|
a.count++
|
|
|
|
|
if strings.EqualFold(st.Result, "NG") {
|
|
|
|
|
a.ng++
|
|
|
|
|
} else {
|
|
|
|
|
a.ok++
|
|
|
|
|
}
|
|
|
|
|
a.stations[atoiSafe(st.StationNo)] = true
|
|
|
|
|
}
|
|
|
|
|
out := make([]RankItem, 0, len(byOp))
|
|
|
|
|
for op, a := range byOp {
|
|
|
|
|
item := RankItem{
|
|
|
|
|
Key: op,
|
|
|
|
|
Label: op,
|
|
|
|
|
Count: a.count,
|
|
|
|
|
OkCount: a.ok,
|
|
|
|
|
NgCount: a.ng,
|
|
|
|
|
OkRate: safeRate(float64(a.ok), float64(a.count)),
|
|
|
|
|
}
|
|
|
|
|
for no := range a.stations {
|
|
|
|
|
item.StationNos = append(item.StationNos, no)
|
|
|
|
|
}
|
|
|
|
|
sort.Ints(item.StationNos)
|
|
|
|
|
out = append(out, item)
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// 报警 / 趋势 / 事件流
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
func (s *Service) buildAlarms(ctx context.Context, today time.Time) []AlarmItem {
|
|
|
|
|
items := []AlarmItem{}
|
|
|
|
|
|
|
|
|
|
if n, err := s.ctx.EntClient.TorqueRecord.Query().
|
|
|
|
|
Where(torquerecord.Result("NG"), torquerecord.TimeGTE(today)).Count(ctx); err == nil && n > 0 {
|
|
|
|
|
items = append(items, AlarmItem{
|
|
|
|
|
ID: "torque-ng", Level: "critical", Type: "quality",
|
|
|
|
|
Target: "拧紧", Message: "当日拧紧 NG " + itoa(n) + " 次", Time: fmtDashboardTime(time.Now()),
|
|
|
|
|
Status: "active",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if n, err := s.ctx.EntClient.PlcSendLog.Query().
|
|
|
|
|
Where(plcsendlog.Status("TIMEOUT")).Count(ctx); err == nil && n > 0 {
|
|
|
|
|
items = append(items, AlarmItem{
|
|
|
|
|
ID: "plc-timeout", Level: "critical", Type: "equipment",
|
|
|
|
|
Target: "PLC", Message: "存在 PLC 下发超时 " + itoa(n) + " 条", Time: fmtDashboardTime(time.Now()),
|
|
|
|
|
Status: "active",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if wh := s.buildWarehouse(ctx); wh.Available && wh.LockedQty > 0 {
|
|
|
|
|
items = append(items, AlarmItem{
|
|
|
|
|
ID: "stock-locked", Level: "warning", Type: "material",
|
|
|
|
|
Target: "库房", Message: "锁定库存 " + itoa(wh.LockedQty) + " 件待出库", Time: fmtDashboardTime(time.Now()),
|
|
|
|
|
Status: "active",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if len(items) == 0 {
|
|
|
|
|
items = []AlarmItem{}
|
|
|
|
|
}
|
|
|
|
|
return items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) buildTrends(ctx context.Context) []TrendPoint {
|
|
|
|
|
points := make([]TrendPoint, 0, 7)
|
|
|
|
|
for i := 6; i >= 0; i-- {
|
|
|
|
|
d := dayStart(time.Now().AddDate(0, 0, -i))
|
|
|
|
|
next := d.Add(24 * time.Hour)
|
|
|
|
|
done := 0
|
|
|
|
|
if n, err := s.ctx.EntClient.Workpiece.Query().
|
|
|
|
|
Where(workpiece.DoneAtGTE(d), workpiece.DoneAtLT(next)).Count(ctx); err == nil {
|
|
|
|
|
done = n
|
|
|
|
|
}
|
|
|
|
|
ok, ng := 0, 0
|
|
|
|
|
if steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
|
|
|
|
|
Where(workpieceprocess.EndedAtGTE(d), workpieceprocess.EndedAtLT(next)).All(ctx); err == nil {
|
|
|
|
|
for _, st := range steps {
|
|
|
|
|
if strings.EqualFold(st.Result, "NG") {
|
|
|
|
|
ng++
|
|
|
|
|
} else {
|
|
|
|
|
ok++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
points = append(points, TrendPoint{
|
|
|
|
|
Time: d.Format("01-02"),
|
|
|
|
|
Output: done,
|
|
|
|
|
QualifiedRate: safeRate(float64(ok), float64(ok+ng)),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return points
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// buildEvents 底部实时事件跑马灯:取最近 20 条工序实绩
|
|
|
|
|
func (s *Service) buildEvents(ctx context.Context) []EventItem {
|
|
|
|
|
steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
|
|
|
|
|
Where(workpieceprocess.EndedAtNotNil()).
|
|
|
|
|
Order(ent.Desc(workpieceprocess.FieldEndedAt)).Limit(20).All(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return []EventItem{}
|
|
|
|
|
}
|
|
|
|
|
out := make([]EventItem, 0, len(steps))
|
|
|
|
|
for _, st := range steps {
|
|
|
|
|
level := "info"
|
|
|
|
|
if strings.EqualFold(st.Result, "NG") {
|
|
|
|
|
level = "error"
|
|
|
|
|
}
|
|
|
|
|
name := st.ProcessName
|
|
|
|
|
if name == "" {
|
|
|
|
|
name = "工序" + itoa(st.ProcessCode)
|
|
|
|
|
}
|
|
|
|
|
out = append(out, EventItem{
|
|
|
|
|
Time: fmtDateTimeHm(orZero(st.EndedAt)),
|
|
|
|
|
Text: "工位" + st.StationNo + " " + st.Operator + " 完成 " + st.Sn + " " + name + " " + st.Result,
|
|
|
|
|
Level: level,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// 辅助
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
func dayStart(t time.Time) time.Time {
|
|
|
|
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func orZero(t *time.Time) time.Time {
|
|
|
|
|
if t == nil {
|
|
|
|
|
return time.Time{}
|
|
|
|
|
}
|
|
|
|
|
return *t
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fmtDateTimeHm(t time.Time) string {
|
|
|
|
|
if t.IsZero() {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return t.Format("15:04")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func pad2(n int) string {
|
|
|
|
|
if n < 10 {
|
|
|
|
|
return "0" + itoa(n)
|
|
|
|
|
}
|
|
|
|
|
return itoa(n)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func atoiSafe(s string) int {
|
|
|
|
|
n, _ := strconv.Atoi(strings.TrimSpace(s))
|
|
|
|
|
return n
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func stationDisplayName(name string, no int) string {
|
|
|
|
|
if strings.TrimSpace(name) != "" {
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
|
return "工位" + itoa(no)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func toInt(v any) int {
|
|
|
|
|
switch x := v.(type) {
|
|
|
|
|
case float64:
|
|
|
|
|
return int(x)
|
|
|
|
|
case int:
|
|
|
|
|
return x
|
|
|
|
|
case int64:
|
|
|
|
|
return int(x)
|
|
|
|
|
case string:
|
|
|
|
|
n, _ := strconv.Atoi(x)
|
|
|
|
|
return n
|
|
|
|
|
}
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// rankFromMap 把 map 排行化,按数量降序;prefix 用于生成展示名(如「工位」)
|
|
|
|
|
func rankFromMap(m map[string]int, prefix string) []RankItem {
|
|
|
|
|
out := make([]RankItem, 0, len(m))
|
|
|
|
|
for k, v := range m {
|
|
|
|
|
if k == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
out = append(out, RankItem{Key: k, Label: prefix + k, Count: v, StationNos: []int{}})
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
|
|
|
|
|
if len(out) > 10 {
|
|
|
|
|
out = out[:10]
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// rankOrderStatus 工单排序权重:进行中优先
|
|
|
|
|
func rankOrderStatus(status string) int {
|
|
|
|
|
switch strings.ToUpper(status) {
|
|
|
|
|
case "IN_PROGRESS":
|
|
|
|
|
return 0
|
|
|
|
|
case "RELEASED":
|
|
|
|
|
return 1
|
|
|
|
|
case "CREATED":
|
|
|
|
|
return 2
|
|
|
|
|
case "COMPLETED":
|
|
|
|
|
return 3
|
|
|
|
|
default:
|
|
|
|
|
return 4
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// sumDailyPlanQty 当日排产计划总量(看板「目标产量」)
|
|
|
|
|
func (s *Service) sumDailyPlanQty(ctx context.Context, date string) int {
|
|
|
|
|
plans, err := s.ctx.EntClient.DailyPlan.Query().
|
|
|
|
|
Where(dailyplan.PlanDateEQ(date)).All(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
sum := 0
|
|
|
|
|
for _, p := range plans {
|
|
|
|
|
sum += p.PlanQty
|
|
|
|
|
}
|
|
|
|
|
return sum
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// sumDailyPlanQtyByOrder 某工单当日排产计划量
|
|
|
|
|
func (s *Service) sumDailyPlanQtyByOrder(ctx context.Context, date, orderNo string) int {
|
|
|
|
|
plans, err := s.ctx.EntClient.DailyPlan.Query().
|
|
|
|
|
Where(dailyplan.PlanDateEQ(date), dailyplan.OrderNoEQ(orderNo)).All(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
sum := 0
|
|
|
|
|
for _, p := range plans {
|
|
|
|
|
sum += p.PlanQty
|
|
|
|
|
}
|
|
|
|
|
return sum
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// countWorkpieceDoneByOrder 某工单当日完工数(日计划达成用)
|
|
|
|
|
func (s *Service) countWorkpieceDoneByOrder(ctx context.Context, today time.Time, orderNo string) int {
|
|
|
|
|
n, err := s.ctx.EntClient.Workpiece.Query().
|
|
|
|
|
Where(
|
|
|
|
|
workpiece.OrderNoEQ(orderNo),
|
|
|
|
|
workpiece.DoneAtGTE(today),
|
|
|
|
|
workpiece.DoneAtLT(today.Add(24*time.Hour)),
|
|
|
|
|
).Count(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return n
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// loadTorqueCriterion 取工艺指标中的扭矩规格(上下限/目标值/单位),用于绘制控制图规格线。
|
|
|
|
|
// 取不到时返回 ok=false,前端不画控制线(不臆造规格值)。
|
|
|
|
|
func (s *Service) loadTorqueCriterion(ctx context.Context) (lo, hi, target float64, unit string, ok bool) {
|
|
|
|
|
crits, err := s.ctx.EntClient.StepCriterion.Query().All(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, 0, 0, "", false
|
|
|
|
|
}
|
|
|
|
|
for _, c := range crits {
|
|
|
|
|
if !strings.Contains(c.Name, "扭矩") && !strings.Contains(c.Name, "扭力") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
lo, hi = 0, 0
|
|
|
|
|
if c.Min != nil {
|
|
|
|
|
lo = *c.Min
|
|
|
|
|
}
|
|
|
|
|
if c.Max != nil {
|
|
|
|
|
hi = *c.Max
|
|
|
|
|
}
|
|
|
|
|
return lo, hi, c.Target, c.Unit, true
|
|
|
|
|
}
|
|
|
|
|
return 0, 0, 0, "", false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// mockAgvProgress 模拟 AGV 任务进度:任务创建后 60 秒线性走完全程。
|
|
|
|
|
// 仅用于 3D 动效,接入 RCS 后由真实坐标替换。
|
|
|
|
|
func mockAgvProgress(created time.Time) float64 {
|
|
|
|
|
elapsed := time.Since(created).Seconds()
|
|
|
|
|
if elapsed <= 0 {
|
2026-08-28 15:06:01 +08:00
|
|
|
return 0
|
|
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
p := elapsed / 60
|
|
|
|
|
if p > 1 {
|
|
|
|
|
return 1
|
2026-08-28 15:06:01 +08:00
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
return p
|
2026-08-28 15:06:01 +08:00
|
|
|
}
|