feat: 五项目业务实现并对接完成

- MES(B): 工单/BOM/备料/工序字典/PLC下发/拧紧/扫码报工/半成品/AGV/追溯逻辑,WMS与海康RCS客户端,看板Redis缓存API(概览/设备/进度/报警/趋势)+SSE
- WMS(C): JWT滑动续签、Excel导入、盘点、内部API、种子数据、独立Postgres配置
- WMS客户端(E): Go网关8891反向代理+内嵌Vue3十页
- 工位终端(D): SQLite本地缓存+模拟拧紧源+内嵌Vue3页面
- Dashboard(A): 看板数据改接MES内部缓存API,SSE实时刷新+vite代理
- 清理各项目球形磨遗留代码,新增部署手册.md
This commit is contained in:
SunYF
2026-08-27 19:50:57 +08:00
parent c85c3bd6f1
commit a2afd2f6dc
335 changed files with 87495 additions and 7357 deletions
@@ -2,13 +2,27 @@ package dashboard
import (
"context"
"encoding/json"
"time"
"bj_power_mes/constants"
"bj_power_mes/ent/dockslot"
"bj_power_mes/ent/job"
"bj_power_mes/ent/workorder"
"bj_power_mes/internal/svc"
"bj_power_mes/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
/*
看板总览(对齐 frontend-api.md 7.1 节):
- 数据来源全部为 MES 库真实统计
- Redis 缓存 60 秒(跨项目 1 分钟过期约定)
- 工单/拧紧/扫码等关键事件通过 SSE event=dashboard_update 通知订阅方刷新
*/
const overviewCacheKey = "mes:dashboard:overview"
type DashboardOverviewLogic struct {
logx.Logger
ctx context.Context
@@ -24,9 +38,116 @@ func NewDashboardOverviewLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
func (l *DashboardOverviewLogic) DashboardOverview() (*types.DashboardOverviewReply, error) {
// 先读缓存
if l.svcCtx.RedisClient != nil {
if val, err := l.svcCtx.RedisClient.Get(overviewCacheKey); err == nil && val != "" {
var cached types.DashboardOverviewReply
if json.Unmarshal([]byte(val), &cached) == nil {
return &cached, nil
}
}
}
reply, err := l.compute()
if err != nil {
return nil, err
}
// 写缓存(60s 过期)
if l.svcCtx.RedisClient != nil {
if b, err := json.Marshal(reply); err == nil {
_ = l.svcCtx.RedisClient.Setex(overviewCacheKey, string(b), 60)
}
}
return reply, nil
}
// compute 实时统计
func (l *DashboardOverviewLogic) compute() (*types.DashboardOverviewReply, error) {
ent := l.svcCtx.EntClient
activeOrderCount, err := ent.WorkOrder.Query().
Where(workorder.StatusIn(
constants.WorkOrderStatus_InProgress,
constants.WorkOrderStatus_Pausing,
)).
Count(l.ctx)
if err != nil {
return nil, err
}
activeJobCount, err := ent.Job.Query().
Where(job.StatusIn(
constants.JobStatus_Created,
constants.JobStatus_Processing,
constants.JobStatus_WaitingUnload,
constants.JobStatus_OnBuffer,
constants.JobStatus_WaitingDecision,
)).
Count(l.ctx)
if err != nil {
return nil, err
}
completedToday, err := ent.Job.Query().
Where(
job.StatusEQ(constants.JobStatus_Completed),
job.CompletedAtGTE(todayStart()),
).
Count(l.ctx)
if err != nil {
return nil, err
}
scrappedToday, err := ent.Job.Query().
Where(
job.StatusEQ(constants.JobStatus_Scrapped),
job.UpdatedAtGTE(todayStart()),
).
Count(l.ctx)
if err != nil {
return nil, err
}
// 暂存台/接驳台占用情况
total, occupied, needRefill := l.bufferStats()
return &types.DashboardOverviewReply{
ActiveOrderCount: activeOrderCount,
ActiveJobCount: activeJobCount,
CompletedToday: completedToday,
ScrappedToday: scrappedToday,
StationFaultCount: 0,
Buffer: types.BufferSummary{
Total: 8,
Total: total,
Occupied: occupied,
Free: total - occupied,
NeedRefill: needRefill,
},
}, nil
}
func (l *DashboardOverviewLogic) bufferStats() (total, occupied int, needRefill bool) {
n, err := l.svcCtx.EntClient.DockSlot.Query().Count(l.ctx)
if err != nil || n == 0 {
return 8, 0, false
}
occ, err := l.svcCtx.EntClient.DockSlot.Query().
Where(dockslot.Not(dockslot.StatusEQ(0))).
Count(l.ctx)
if err != nil {
return n, 0, false
}
refill := n > 0 && occ*5 >= n*4 // 占用 ≥80% 提示补料
return n, occ, refill
}
func todayStart() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
}
// EmitDashboardChange 业务模块在任何看板相关变更后调用,通知 SSE 订阅方刷新
func EmitDashboardChange(svcCtx *svc.ServiceContext) {
svcCtx.SSEHandler.Emit("dashboard_update", "{}")
}
@@ -0,0 +1,326 @@
package dashboard
import (
"context"
"encoding/json"
"sort"
"time"
"bj_power_mes/constants"
"bj_power_mes/ent/alarm"
"bj_power_mes/ent/equipment"
"bj_power_mes/ent/equipmentslot"
"bj_power_mes/ent/job"
"bj_power_mes/ent/workorder"
"bj_power_mes/internal/svc"
"github.com/zeromicro/go-zero/core/logx"
)
/*
看板明细数据(设备/进度/报警/趋势),供 Dashboard 内部 API 使用:
- 数据来源全部为 MES 库真实统计
- Redis 缓存 60 秒(跨项目 1 分钟过期约定,同 overview
- 关键事件通过 SSE event=dashboard_update 通知订阅方刷新
*/
type SnapshotLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewSnapshotLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SnapshotLogic {
return &SnapshotLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
// cached 统一的 60s Redis 缓存包装
func (l *SnapshotLogic) cached(key string, fn func() (any, error)) (any, error) {
if l.svcCtx.RedisClient != nil {
if val, err := l.svcCtx.RedisClient.Get(key); err == nil && val != "" {
var v any
if json.Unmarshal([]byte(val), &v) == nil {
return v, nil
}
}
}
data, err := fn()
if err != nil {
return nil, err
}
if l.svcCtx.RedisClient != nil {
if b, err := json.Marshal(data); err == nil {
_ = l.svcCtx.RedisClient.Setex(key, string(b), 60)
}
}
return data, nil
}
// EquipmentList 设备状态列表(含槽位占用统计)
func (l *SnapshotLogic) EquipmentList() (any, error) {
return l.cached("mes:dashboard:equipment", l.computeEquipment)
}
func (l *SnapshotLogic) computeEquipment() (any, error) {
ent := l.svcCtx.EntClient
equipments, err := ent.Equipment.Query().
WithEquipmentType().
Order(equipment.ByID()).
All(l.ctx)
if err != nil {
return nil, err
}
// 槽位占用统计(非 EMPTY 视为占用)
type slotStat struct{ occupied int }
slotMap := map[int]*slotStat{}
allSlots, err := ent.EquipmentSlot.Query().
Where(equipmentslot.Not(
equipmentslot.StatusEQ(constants.SlotStatus_Empty)),
).
All(l.ctx)
if err != nil {
return nil, err
}
for _, s := range allSlots {
if s.EquipmentId == nil {
continue
}
st, ok := slotMap[*s.EquipmentId]
if !ok {
st = &slotStat{}
slotMap[*s.EquipmentId] = st
}
st.occupied++
}
list := make([]map[string]any, 0, len(equipments))
for _, e := range equipments {
occupied := 0
if st := slotMap[e.ID]; st != nil {
occupied = st.occupied
}
typ := e.Edges.EquipmentType
typeCode, typeName := "", ""
if typ != nil {
typeCode = string(typ.Code)
typeName = typ.Name
}
list = append(list, map[string]any{
"id": e.ID,
"name": e.Name,
"type_code": typeCode,
"type_name": typeName,
"status": string(e.Status),
"slot_count": e.SlotCount,
"occupied_slots": occupied,
"location": e.Location,
})
}
return list, nil
}
// ProgressList 生产进度(最近50条工单,含完成百分比)
func (l *SnapshotLogic) ProgressList() (any, error) {
return l.cached("mes:dashboard:progress", l.computeProgress)
}
func (l *SnapshotLogic) computeProgress() (any, error) {
orders, err := l.svcCtx.EntClient.WorkOrder.Query().
WithProductType().
Order(workorder.ByCreatedAt()).
Limit(50).
All(l.ctx)
if err != nil {
return nil, err
}
list := make([]map[string]any, 0, len(orders))
for _, o := range orders {
progress := 0
if o.Quantity > 0 {
progress = o.FinishedNum * 100 / o.Quantity
}
productName, category := "", ""
if pt := o.Edges.ProductType; pt != nil {
productName = pt.Name
category = string(pt.Category)
}
list = append(list, map[string]any{
"id": o.ID,
"work_order_no": o.WorkOrderNo,
"product_name": productName,
"category": category,
"status": string(o.Status),
"quantity": o.Quantity,
"finished_num": o.FinishedNum,
"fail_num": o.FailNum,
"progress": progress,
})
}
return list, nil
}
// AlarmList 报警列表(未处理优先 + 最近50条)
func (l *SnapshotLogic) AlarmList() (any, error) {
return l.cached("mes:dashboard:alarms", l.computeAlarms)
}
func (l *SnapshotLogic) computeAlarms() (any, error) {
ent := l.svcCtx.EntClient
alarms, err := ent.Alarm.Query().
Order(alarm.ByCreatedAt()).
Limit(200).
All(l.ctx)
if err != nil {
return nil, err
}
eqNames := map[int]string{}
equipments, _ := ent.Equipment.Query().All(l.ctx)
for _, e := range equipments {
eqNames[e.ID] = e.Name
}
type row struct {
resolved bool
at time.Time
item map[string]any
}
rows := make([]row, 0, len(alarms))
for _, a := range alarms {
item := map[string]any{
"id": a.ID,
"alarm_code": a.AlarmCode,
"alarm_message": a.AlarmMessage,
"level": string(a.Level),
"source": a.Source,
"resolved": a.Resolved,
"created_at": a.CreatedAt.Format(time.RFC3339),
"equipment_id": nil,
"equipment_name": "",
}
if a.EquipmentId != nil {
item["equipment_id"] = *a.EquipmentId
item["equipment_name"] = eqNames[*a.EquipmentId]
}
rows = append(rows, row{resolved: a.Resolved, at: a.CreatedAt, item: item})
}
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].resolved != rows[j].resolved {
return !rows[i].resolved // 未处理在前
}
return rows[i].at.After(rows[j].at) // 新的在前
})
list := make([]map[string]any, 0, 50)
for i, r := range rows {
if i >= 50 {
break
}
list = append(list, r.item)
}
return list, nil
}
// TrendList 近10日产量趋势(按产品分类分组:完成数/报废数,10日×分类全覆盖)
func (l *SnapshotLogic) TrendList() (any, error) {
return l.cached("mes:dashboard:trends", l.computeTrends)
}
func (l *SnapshotLogic) computeTrends() (any, error) {
ent := l.svcCtx.EntClient
start := todayStart().AddDate(0, 0, -9)
jobs, err := ent.Job.Query().
Where(job.StatusIn(constants.JobStatus_Completed, constants.JobStatus_Scrapped)).
All(l.ctx)
if err != nil {
return nil, err
}
// 工单ID → 产品分类
categoryOf := map[int]string{}
workOrders, _ := ent.WorkOrder.Query().WithProductType().All(l.ctx)
for _, o := range workOrders {
cat := ""
if pt := o.Edges.ProductType; pt != nil {
cat = string(pt.Category)
}
categoryOf[o.ID] = cat
}
// date → category → [completed, scrapped]
buckets := map[string][2]int{}
cats := map[string]bool{}
dateSet := map[string]bool{}
dates := make([]string, 0, 10)
for i := 0; i < 10; i++ {
d := start.AddDate(0, 0, i).Format("01-02")
dateSet[d] = true
dates = append(dates, d)
}
for _, j := range jobs {
ts := j.CompletedAt
if ts.IsZero() || j.Status == constants.JobStatus_Scrapped {
// 无完成时间的报废按更新时间近似
if up := j.UpdatedAt; up != nil {
ts = *up
} else {
continue
}
}
d := ts.Format("01-02")
if !dateSet[d] {
continue
}
cat := categoryOf[j.WorkOrderId]
if cat == "" {
cat = "OTHER"
}
cats[cat] = true
key := d + "|" + cat
b := buckets[key]
if j.Status == constants.JobStatus_Completed {
b[0]++
} else {
b[1]++
}
buckets[key] = b
}
if len(cats) == 0 {
for _, c := range []string{"A6VM107", "A6VM160", "A6VM200"} {
cats[c] = true
}
}
catKeys := make([]string, 0, len(cats))
for c := range cats {
catKeys = append(catKeys, c)
}
sort.Strings(catKeys)
list := make([]map[string]any, 0, 30)
for _, d := range dates {
for _, c := range catKeys {
b := buckets[d+"|"+c]
list = append(list, map[string]any{
"date": d,
"category": c,
"completed": b[0],
"scrapped": b[1],
})
}
}
return list, nil
}