feat(mes): 添加定时同步可生产数量到WMS及BOM项相关标准字段
- 在main.go中添加定时任务每10分钟同步可生产数量到WMS系统 - 为BomItem实体添加relatedStandard字段及相关CRUD方法 - 为InspectionRecord实体添加reportNo、materialCode、materialName等字段 - 更新ent schema确保新字段的验证和默认值设置 - 添加必要的数据库迁移和字段映射逻辑
This commit is contained in:
@@ -21,11 +21,6 @@ type InternalConfig struct {
|
||||
Token string `json:",default=Hardman_2026"`
|
||||
}
|
||||
|
||||
// SimConfig 内置模拟拧紧枪数据源开关
|
||||
type SimConfig struct {
|
||||
Enable bool `json:",default=true"`
|
||||
}
|
||||
|
||||
// SqliteConfig 本地 SQLite 缓存库
|
||||
type SqliteConfig struct {
|
||||
Path string `json:",default=workstation.db"`
|
||||
@@ -34,8 +29,7 @@ type SqliteConfig struct {
|
||||
// StationConfig 工位配置:启动时由 yaml 加载到内存,固定本终端工位号。
|
||||
// 一个工位一个终端,终端不允许修改工位;变更工位只能改配置并重启服务。
|
||||
type StationConfig struct {
|
||||
StationNo int `json:",default=1"` // 固定工位号,启动时校验范围
|
||||
ScrewCount int `json:",default=10"` // 本工位需拧紧的螺丝颗数(进度 9/10 用)
|
||||
StationNo int `json:",default=1"` // 固定工位号,启动时校验范围
|
||||
}
|
||||
|
||||
// PdfCacheConfig 工艺文件 PDF 本地预缓存目录
|
||||
@@ -48,7 +42,6 @@ type Config struct {
|
||||
Auth AuthConfig `json:",optional"`
|
||||
Mes MesConfig `json:",optional"`
|
||||
Internal InternalConfig `json:",optional"`
|
||||
Sim SimConfig `json:",optional"`
|
||||
Sqlite SqliteConfig `json:",optional"`
|
||||
Station StationConfig `json:",optional"`
|
||||
PdfCache PdfCacheConfig `json:",optional"`
|
||||
|
||||
@@ -79,12 +79,11 @@ func logoutHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// configHandler GET /api/config 返回工位配置:// 固定工位号 stationNo(由 yaml 配置在启动时加载,终端不可修改)、本工位需拧紧的螺丝颗数。
|
||||
// configHandler GET /api/config 返回工位配置:固定工位号 stationNo(由 yaml 配置在启动时加载,终端不可修改)。
|
||||
func configHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ok(w, map[string]any{
|
||||
"stationNo": ctx.Config.Station.StationNo,
|
||||
"screwCount": ctx.Config.Station.ScrewCount,
|
||||
"stationNo": ctx.Config.Station.StationNo,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func pdfQueryHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// pdfOpenHandler GET /pdfopen/<name>(免鉴权,供 iframe 直链)
|
||||
// pdfOpenHandler GET /pdfopen/<name>(免鉴权,供 iframe 直链;仅单层文件名)
|
||||
func pdfOpenHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
raw := strings.TrimPrefix(r.URL.EscapedPath(), "/pdfopen/")
|
||||
@@ -107,14 +107,26 @@ func pdfOpenHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// forwardFile 校验单段文件名后拉取 MES 文件流并原样转发;优先命中本地预缓存。
|
||||
// pdfViewHandler GET /pdfview?name=<encoded>(免鉴权,注册在鉴权中间件之前的路由组)。
|
||||
// 以 query 传文件名,天然支持 MES 落盘的「日期子目录/文件名」(如 2026-09-16/xxx.pdf),
|
||||
// 供工艺流程 PDF 与工序步骤图纸附件(Item J)统一使用。
|
||||
func pdfViewHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
forwardFile(w, ctx.Mes, ctx.PdfCache, strings.TrimSpace(r.URL.Query().Get("name")))
|
||||
}
|
||||
}
|
||||
|
||||
// forwardFile 校验文件名后拉取 MES 文件流并原样转发;优先命中本地预缓存。
|
||||
// 允许「单层日期子目录/文件名」(MES 上传落盘格式),拒绝路径穿越与多级/绝对路径。
|
||||
func forwardFile(w http.ResponseWriter, mesc *mes.Client, cache *pdfcache.Cache, name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
fail(w, http.StatusBadRequest, "缺少文件名")
|
||||
return
|
||||
}
|
||||
if strings.ContainsAny(name, `/\`) || name == "." || name == ".." || strings.Contains(name, "..") {
|
||||
if strings.Contains(name, "..") || strings.ContainsAny(name, `\`) ||
|
||||
strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") ||
|
||||
strings.Count(name, "/") > 1 || name == "." {
|
||||
fail(w, http.StatusBadRequest, "非法文件名")
|
||||
return
|
||||
}
|
||||
@@ -129,8 +141,8 @@ func forwardFile(w http.ResponseWriter, mesc *mes.Client, cache *pdfcache.Cache,
|
||||
f.Close()
|
||||
}
|
||||
|
||||
// 2) 回源 MES 下载
|
||||
body, header, err := mesc.Get("/api/internal/files/"+url.PathEscape(name), nil)
|
||||
// 2) 回源 MES 下载(内部文件接口按 query 参数 name 读取,支持日期子目录)
|
||||
body, header, err := mesc.Get("/api/internal/files", url.Values{"name": {name}})
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadGateway, "工艺文件获取失败:"+errString(err))
|
||||
return
|
||||
|
||||
@@ -59,6 +59,9 @@ func processDoneHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
BindValue string `json:"bindValue"`
|
||||
} `json:"binds"`
|
||||
Operator string `json:"operator"`
|
||||
// 作业时长采集(P0-3):上料时刻/报工时刻(ISO8601),原样转发 MES 算 durationSec;离线重传时同 payload 保留
|
||||
StartedAt string `json:"startedAt"`
|
||||
EndedAt string `json:"endedAt"`
|
||||
}
|
||||
if err := parseJSON(r, &req); err != nil {
|
||||
fail(w, http.StatusBadRequest, "参数错误")
|
||||
@@ -83,6 +86,12 @@ func processDoneHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
if len(req.Binds) > 0 {
|
||||
body["binds"] = req.Binds
|
||||
}
|
||||
if req.StartedAt != "" {
|
||||
body["startedAt"] = req.StartedAt
|
||||
}
|
||||
if req.EndedAt != "" {
|
||||
body["endedAt"] = req.EndedAt
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(body)
|
||||
|
||||
ctx.Store.AddEventLog(req.OrderNo, req.Operator, "process_done",
|
||||
|
||||
@@ -16,6 +16,8 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
|
||||
{Method: http.MethodGet, Path: "/api/health", Handler: healthHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/auth/login", Handler: loginHandler(ctx)},
|
||||
{Method: http.MethodGet, Path: "/api/config", Handler: configHandler(ctx)},
|
||||
// 工艺文件免鉴权直链(query 传名,支持日期子目录;供 iframe/window.open)
|
||||
{Method: http.MethodGet, Path: "/pdfview", Handler: pdfViewHandler(ctx)},
|
||||
// 前端静态托管
|
||||
{Method: http.MethodGet, Path: "/", Handler: indexHandler(ctx.WebFS)},
|
||||
{Method: http.MethodGet, Path: "/assets/:file", Handler: assetHandler(ctx.WebFS)},
|
||||
@@ -37,6 +39,7 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
|
||||
{Method: http.MethodGet, Path: "/api/pdf", Handler: pdfQueryHandler(ctx)},
|
||||
{Method: http.MethodGet, Path: "/pdfopen/:name", Handler: pdfOpenHandler(ctx)},
|
||||
{Method: http.MethodGet, Path: "/api/tightening/list", Handler: tighteningListHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/tightening/record", Handler: tighteningRecordHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/report/process-done", Handler: processDoneHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/report/temp-store", Handler: tempStoreHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/station/logout", Handler: logoutHandler(ctx)},
|
||||
@@ -58,6 +61,14 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
|
||||
{Method: http.MethodPost, Path: "/api/qty-report", Handler: qtyReportHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/return-material", Handler: returnMaterialHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/inspection/upload", Handler: inspectionUploadHandler(ctx)},
|
||||
|
||||
// 在制品 / 0-13 上下线建档完工 / BOM 领料 / 追溯(代理 MES 内部 API)
|
||||
{Method: http.MethodGet, Path: "/api/wip", Handler: wipHandler(ctx)},
|
||||
{Method: http.MethodGet, Path: "/api/bom", Handler: bomHandler(ctx)},
|
||||
{Method: http.MethodGet, Path: "/api/order/query", Handler: orderQueryHandler(ctx)},
|
||||
{Method: http.MethodGet, Path: "/api/trace", Handler: traceHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/workpiece/online", Handler: onlineHandler(ctx)},
|
||||
{Method: http.MethodPost, Path: "/api/workpiece/done", Handler: doneHandler(ctx)},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"bj_power_workstation/internal/store"
|
||||
"bj_power_workstation/internal/svc"
|
||||
)
|
||||
|
||||
@@ -34,4 +39,65 @@ func tighteningListHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
"list": list,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// tighteningRecordHandler POST /api/tightening/record —— 录入一条拧紧数据。
|
||||
// 真实场景:工件走到「需要拧紧枪」的工艺步骤时界面聚焦拧紧输入框,人工操作拧紧枪把采集到的
|
||||
// 扭矩值打入输入框回车 → 本接口按当前工件 SN + 工艺步骤落本地库,再由后台同步器上报 MES。
|
||||
// 入参 {sn, workOrderNo, stepId, stepName, screwNo, torque, angle, result, operator};
|
||||
// result 由前端按该步骤考核标准(扭矩 RANGE)判定后传入,非 NG 一律记 OK。
|
||||
func tighteningRecordHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, "请求体读取失败")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Sn string `json:"sn"`
|
||||
WorkOrderNo string `json:"workOrderNo"`
|
||||
StepId string `json:"stepId"`
|
||||
StepName string `json:"stepName"`
|
||||
ScrewNo string `json:"screwNo"`
|
||||
Torque float64 `json:"torque"`
|
||||
Angle float64 `json:"angle"`
|
||||
Result string `json:"result"`
|
||||
Operator string `json:"operator"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
fail(w, http.StatusBadRequest, "请求体解析失败")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Sn) == "" {
|
||||
fail(w, http.StatusBadRequest, "缺少工件 SN,请先上料")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.StepId) == "" {
|
||||
fail(w, http.StatusBadRequest, "缺少拧紧工艺步骤")
|
||||
return
|
||||
}
|
||||
if req.Result != "NG" {
|
||||
req.Result = "OK"
|
||||
}
|
||||
dockCode := fmt.Sprintf("DOCK%02d", ctx.Config.Station.StationNo)
|
||||
id, err := ctx.Store.InsertTorqueResult(store.TorqueResult{
|
||||
WorkOrderNo: req.WorkOrderNo,
|
||||
Sn: req.Sn,
|
||||
DockCode: dockCode,
|
||||
ScrewNo: req.ScrewNo,
|
||||
StepId: req.StepId,
|
||||
StepName: req.StepName,
|
||||
Torque: req.Torque,
|
||||
Angle: req.Angle,
|
||||
Result: req.Result,
|
||||
Operator: req.Operator,
|
||||
})
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, "拧紧数据入库失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Store.AddEventLog(req.WorkOrderNo, req.Operator, "torque_record",
|
||||
fmt.Sprintf("拧紧录入 sn=%s 步骤=%s 扭矩=%.1f 结果=%s", req.Sn, req.StepName, req.Torque, req.Result))
|
||||
ok(w, map[string]any{"id": id, "result": req.Result})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"bj_power_workstation/internal/svc"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
// passJSON 原样透传 MES 的 {code,message,data} 响应体(不再二次包装)。
|
||||
func passJSON(w http.ResponseWriter, body []byte) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// wipHandler GET /api/wip?stationNo= —— 代理 MES /api/internal/workpieces/in-process。
|
||||
// 在制品默认展示(5.2):停在本工位/上线位(0)/下线位(13) 的未完工工件列表。
|
||||
func wipHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
params := url.Values{}
|
||||
if sn := strings.TrimSpace(r.URL.Query().Get("stationNo")); sn != "" {
|
||||
params.Set("stationNo", sn)
|
||||
}
|
||||
body, _, err := ctx.Mes.Get("/api/internal/workpieces/in-process", params)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadGateway, "MES 服务不可达:"+errString(err))
|
||||
return
|
||||
}
|
||||
passJSON(w, body)
|
||||
}
|
||||
}
|
||||
|
||||
// bomHandler GET /api/bom?productCode=&bomName= —— 代理 MES /api/internal/bom。
|
||||
// 上线建档「领料确认」按物料清单展示应领料。
|
||||
func bomHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
params := url.Values{}
|
||||
if v := strings.TrimSpace(r.URL.Query().Get("productCode")); v != "" {
|
||||
params.Set("productCode", v)
|
||||
}
|
||||
if v := strings.TrimSpace(r.URL.Query().Get("bomName")); v != "" {
|
||||
params.Set("bomName", v)
|
||||
}
|
||||
body, _, err := ctx.Mes.Get("/api/internal/bom", params)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadGateway, "MES 服务不可达:"+errString(err))
|
||||
return
|
||||
}
|
||||
passJSON(w, body)
|
||||
}
|
||||
}
|
||||
|
||||
// orderQueryHandler GET /api/order/query?orderNo= —— 代理 MES /api/internal/order/query。
|
||||
// 上线建档选工单后带出产品编码/名称(用于查 BOM 应领料)。
|
||||
func orderQueryHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
params := url.Values{}
|
||||
if v := strings.TrimSpace(r.URL.Query().Get("orderNo")); v != "" {
|
||||
params.Set("orderNo", v)
|
||||
}
|
||||
body, _, err := ctx.Mes.Get("/api/internal/order/query", params)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadGateway, "MES 服务不可达:"+errString(err))
|
||||
return
|
||||
}
|
||||
passJSON(w, body)
|
||||
}
|
||||
}
|
||||
|
||||
// traceHandler GET /api/trace?sn= —— 代理 MES /api/internal/workpiece/trace。
|
||||
// 下线完工面板扫 SN 后展示工件状态/工序时间线/拧紧等追溯信息。
|
||||
func traceHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sn := strings.TrimSpace(r.URL.Query().Get("sn"))
|
||||
if sn == "" {
|
||||
fail(w, http.StatusBadRequest, "缺少 sn 参数")
|
||||
return
|
||||
}
|
||||
body, _, err := ctx.Mes.Get("/api/internal/workpiece/trace", url.Values{"sn": {sn}})
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadGateway, "MES 服务不可达:"+errString(err))
|
||||
return
|
||||
}
|
||||
passJSON(w, body)
|
||||
}
|
||||
}
|
||||
|
||||
// onlineHandler POST /api/workpiece/online —— 上线建档(StationNo=0)。
|
||||
// {sn, orderNo, workOrderId, processSeq, operator};MES 不可达时写离线队列(kind=online_register)稍后重传。
|
||||
func onlineHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, "请求体读取失败")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Sn string `json:"sn"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
Operator string `json:"operator"`
|
||||
}
|
||||
_ = json.Unmarshal(payload, &req)
|
||||
if strings.TrimSpace(req.Sn) == "" {
|
||||
fail(w, http.StatusBadRequest, "缺少 sn")
|
||||
return
|
||||
}
|
||||
ctx.Syncer.RunOnce(r.Context())
|
||||
ctx.Store.AddEventLog(req.OrderNo, req.Operator, "online_register", "上线建档 sn="+req.Sn)
|
||||
|
||||
q := url.Values{}
|
||||
if req.Operator != "" {
|
||||
q.Set("operator", req.Operator)
|
||||
}
|
||||
respBody, _, err := ctx.Mes.Post("/api/internal/workpiece/online?"+q.Encode(), json.RawMessage(payload))
|
||||
if err != nil {
|
||||
if qerr := ctx.Store.InsertQueueItem("online_register", string(payload)); qerr != nil {
|
||||
fail(w, http.StatusInternalServerError, "上报失败且离线队列写入失败:"+qerr.Error())
|
||||
return
|
||||
}
|
||||
logx.Errorf("上线建档失败已离线排队: %v", err)
|
||||
ok(w, map[string]any{"queued": true})
|
||||
return
|
||||
}
|
||||
if msg := mesRespCode(respBody); msg != "" {
|
||||
fail(w, http.StatusConflict, msg)
|
||||
return
|
||||
}
|
||||
ok(w, map[string]any{"queued": false})
|
||||
}
|
||||
}
|
||||
|
||||
// doneHandler POST /api/workpiece/done —— 下线完工入库(StationNo=13)。
|
||||
// {sn, batchItems, serialItems, operator};完工内部自动回流 WMS 成品库存;
|
||||
// MES 不可达时写离线队列(kind=offline_finish)稍后重传。
|
||||
func doneHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, "请求体读取失败")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Sn string `json:"sn"`
|
||||
Operator string `json:"operator"`
|
||||
}
|
||||
_ = json.Unmarshal(payload, &req)
|
||||
if strings.TrimSpace(req.Sn) == "" {
|
||||
fail(w, http.StatusBadRequest, "缺少 sn")
|
||||
return
|
||||
}
|
||||
ctx.Syncer.RunOnce(r.Context())
|
||||
ctx.Store.AddEventLog("", req.Operator, "offline_finish", "下线完工入库 sn="+req.Sn)
|
||||
|
||||
q := url.Values{}
|
||||
if req.Operator != "" {
|
||||
q.Set("operator", req.Operator)
|
||||
}
|
||||
respBody, _, err := ctx.Mes.Post("/api/internal/workpiece/done?"+q.Encode(), json.RawMessage(payload))
|
||||
if err != nil {
|
||||
if qerr := ctx.Store.InsertQueueItem("offline_finish", string(payload)); qerr != nil {
|
||||
fail(w, http.StatusInternalServerError, "上报失败且离线队列写入失败:"+qerr.Error())
|
||||
return
|
||||
}
|
||||
logx.Errorf("下线完工上报失败已离线排队: %v", err)
|
||||
ok(w, map[string]any{"queued": true})
|
||||
return
|
||||
}
|
||||
if msg := mesRespCode(respBody); msg != "" {
|
||||
fail(w, http.StatusConflict, msg)
|
||||
return
|
||||
}
|
||||
ok(w, map[string]any{"queued": false})
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS torque_results (
|
||||
sn TEXT,
|
||||
dock_code TEXT,
|
||||
screw_no TEXT,
|
||||
step_id TEXT,
|
||||
step_name TEXT,
|
||||
torque REAL,
|
||||
angle REAL,
|
||||
result TEXT,
|
||||
@@ -82,6 +84,10 @@ func Open(path string) (*Store, error) {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := s.migrateTorqueStep(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -99,6 +105,29 @@ func (s *Store) migrateUsersRole() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// migrateTorqueStep 兼容旧库:拧紧结果关联工艺步骤(step_id/step_name)为后续补充字段,缺失时自动补齐。
|
||||
func (s *Store) migrateTorqueStep() error {
|
||||
cols := []struct{ name, ddl string }{
|
||||
{"step_id", `ALTER TABLE torque_results ADD COLUMN step_id TEXT`},
|
||||
{"step_name", `ALTER TABLE torque_results ADD COLUMN step_name TEXT`},
|
||||
}
|
||||
for _, col := range cols {
|
||||
rows, err := s.db.Query(`SELECT name FROM pragma_table_info('torque_results') WHERE name=?`, col.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exists := rows.Next()
|
||||
rows.Close()
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
if _, err := s.db.Exec(col.ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// Ping 健康检查用。
|
||||
@@ -151,6 +180,8 @@ type TorqueResult struct {
|
||||
Sn string `json:"sn"`
|
||||
DockCode string `json:"dockCode"`
|
||||
ScrewNo string `json:"screwNo"`
|
||||
StepId string `json:"stepId"` // 关联的拧紧工艺步骤ID
|
||||
StepName string `json:"stepName"` // 拧紧工艺步骤名
|
||||
Torque float64 `json:"torque"`
|
||||
Angle float64 `json:"angle"`
|
||||
Result string `json:"result"` // OK | NG
|
||||
@@ -161,9 +192,9 @@ type TorqueResult struct {
|
||||
|
||||
func (s *Store) InsertTorqueResult(t TorqueResult) (int64, error) {
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO torque_results(work_order_no, sn, dock_code, screw_no, torque, angle, result, operator, synced, created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,0,?)`,
|
||||
t.WorkOrderNo, t.Sn, t.DockCode, t.ScrewNo, t.Torque, t.Angle, t.Result, t.Operator, time.Now().Unix(),
|
||||
`INSERT INTO torque_results(work_order_no, sn, dock_code, screw_no, step_id, step_name, torque, angle, result, operator, synced, created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,0,?)`,
|
||||
t.WorkOrderNo, t.Sn, t.DockCode, t.ScrewNo, t.StepId, t.StepName, t.Torque, t.Angle, t.Result, t.Operator, time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -173,7 +204,7 @@ func (s *Store) InsertTorqueResult(t TorqueResult) (int64, error) {
|
||||
|
||||
// ListTorqueResults 查询本地拧紧结果,最新优先;sn 为空查全部,onlyPending 仅查未同步。
|
||||
func (s *Store) ListTorqueResults(sn string, onlyPending bool, limit int) ([]TorqueResult, error) {
|
||||
query := `SELECT id, work_order_no, sn, dock_code, screw_no, torque, angle, result, operator, synced, created_at FROM torque_results`
|
||||
query := `SELECT id, work_order_no, sn, dock_code, screw_no, COALESCE(step_id,''), COALESCE(step_name,''), torque, angle, result, operator, synced, created_at FROM torque_results`
|
||||
var conds []string
|
||||
var args []any
|
||||
if sn != "" {
|
||||
@@ -201,7 +232,7 @@ func (s *Store) ListTorqueResults(sn string, onlyPending bool, limit int) ([]Tor
|
||||
var synced int64
|
||||
var createdAt int64
|
||||
if err := rows.Scan(&t.ID, &t.WorkOrderNo, &t.Sn, &t.DockCode, &t.ScrewNo,
|
||||
&t.Torque, &t.Angle, &t.Result, &t.Operator, &synced, &createdAt); err != nil {
|
||||
&t.StepId, &t.StepName, &t.Torque, &t.Angle, &t.Result, &t.Operator, &synced, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Synced = synced == 1
|
||||
|
||||
@@ -3,6 +3,7 @@ package syncer
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bj_power_workstation/internal/mes"
|
||||
@@ -53,7 +54,7 @@ func (s *Syncer) RunOnce(ctx context.Context) int {
|
||||
}
|
||||
|
||||
// flushTorque 上报未同步拧紧结果:
|
||||
// POST /api/internal/tightening/report;成功标记 synced=1,失败保持 0 等待下轮重试。
|
||||
// POST /api/internal/torque/report;成功标记 synced=1,失败保持 0 等待下轮重试。
|
||||
func (s *Syncer) flushTorque(ctx context.Context) int {
|
||||
rows, err := s.st.ListTorqueResults("", true, reportBatchLimit)
|
||||
if err != nil {
|
||||
@@ -70,17 +71,24 @@ func (s *Syncer) flushTorque(ctx context.Context) int {
|
||||
default:
|
||||
}
|
||||
|
||||
// MES 内部接口以 stationNo(数字工位号)落库,并据此做应拧/实拧分组统计;
|
||||
// 本地存的是 DOCK 编码(DOCK01 ↔ 工位1),上报前换算为工位号。
|
||||
stationNo := strings.TrimLeft(strings.TrimPrefix(t.DockCode, "DOCK"), "0")
|
||||
if stationNo == "" {
|
||||
stationNo = "0"
|
||||
}
|
||||
body := map[string]any{
|
||||
"workOrderNo": t.WorkOrderNo,
|
||||
"sn": t.Sn,
|
||||
"dockCode": t.DockCode,
|
||||
"stationNo": stationNo,
|
||||
"screwNo": t.ScrewNo,
|
||||
"torque": t.Torque,
|
||||
"angle": t.Angle,
|
||||
"result": t.Result,
|
||||
"operator": t.Operator,
|
||||
}
|
||||
if _, _, err := s.mes.Post("/api/internal/tightening/report", body); err != nil {
|
||||
if _, _, err := s.mes.Post("/api/internal/torque/report", body); err != nil {
|
||||
logx.Errorf("上报拧紧结果失败(id=%d sn=%s): %v", t.ID, t.Sn, err)
|
||||
continue
|
||||
}
|
||||
@@ -96,8 +104,10 @@ func (s *Syncer) flushTorque(ctx context.Context) int {
|
||||
}
|
||||
|
||||
// flushQueue 上报未同步的离线缓存队列,payload 原样透传:
|
||||
// - kind=process_done → POST /api/internal/station/report
|
||||
// - kind=temp_store → POST /api/internal/station/checkin
|
||||
// - kind=process_done → POST /api/internal/station/report
|
||||
// - kind=temp_store → POST /api/internal/station/checkin
|
||||
// - kind=online_register → POST /api/internal/workpiece/online(上线建档)
|
||||
// - kind=offline_finish → POST /api/internal/workpiece/done(下线完工入库)
|
||||
//
|
||||
// 成功标记 synced=1;失败 synced 保持 0 并累计 retry_count。
|
||||
func (s *Syncer) flushQueue(ctx context.Context) int {
|
||||
@@ -121,6 +131,10 @@ func (s *Syncer) flushQueue(ctx context.Context) int {
|
||||
path = "/api/internal/station/report"
|
||||
case "temp_store":
|
||||
path = "/api/internal/station/checkin"
|
||||
case "online_register":
|
||||
path = "/api/internal/workpiece/online"
|
||||
case "offline_finish":
|
||||
path = "/api/internal/workpiece/done"
|
||||
default:
|
||||
logx.Errorf("未知队列类型(kind=%s id=%d),跳过", it.Kind, it.ID)
|
||||
_ = s.st.BumpQueueRetry(it.ID)
|
||||
|
||||
@@ -2,127 +2,28 @@ package tightening
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"bj_power_workstation/internal/mes"
|
||||
"bj_power_workstation/internal/store"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
// Source 拧紧枪数据源接口。
|
||||
//
|
||||
// 真实拧紧枪(丹尼科尔)是 HID/网络输入设备:工件走到「需要拧紧枪」的工艺步骤时,
|
||||
// 工位终端界面聚焦到该步骤的拧紧输入框,人工操作拧紧枪把采集到的扭矩值打入输入框回车,
|
||||
// 前端随即 POST /api/tightening/record,按当前工件 SN + 工艺步骤落本地库并上报 MES。
|
||||
// 因此拧紧数据由「工艺步骤 + 人工拧枪」驱动,不再需要后台自由产生模拟数据的源。
|
||||
type Source interface {
|
||||
Start(ctx context.Context)
|
||||
}
|
||||
|
||||
// MockSource 内置模拟拧紧枪数据源:每 6 秒生成一条拧紧结果插入本地 torque_results(synced=0)。
|
||||
// SN 从最近工单任务中获取;拿不到则用 "MOCK-"+时间戳。
|
||||
type MockSource struct {
|
||||
db *store.Store
|
||||
mes *mes.Client
|
||||
dockCode string // 模拟枪挂载的工位
|
||||
}
|
||||
|
||||
// NewMockSource 模拟枪挂载在固定工位(stationNo 由 yaml 配置),DOCK 编码为 DOCK01~DOCK12。
|
||||
func NewMockSource(db *store.Store, mc *mes.Client, stationNo int) *MockSource {
|
||||
dockCode := fmt.Sprintf("DOCK%02d", stationNo)
|
||||
return &MockSource{db: db, mes: mc, dockCode: dockCode}
|
||||
}
|
||||
|
||||
func (m *MockSource) Start(ctx context.Context) {
|
||||
logx.Infof("模拟拧紧枪已启动:每 6 秒采集一条,工位 %s", m.dockCode)
|
||||
ticker := time.NewTicker(6 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logx.Info("模拟拧紧枪已停止")
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.produceOne()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockSource) produceOne() {
|
||||
sn, orderNo := m.fetchRecentTask()
|
||||
if sn == "" {
|
||||
sn = "MOCK-" + time.Now().Format("20060102150405")
|
||||
orderNo = "MOCK-ORDER"
|
||||
}
|
||||
|
||||
torque := round1(40 + rand.Float64()*12) // 40 ~ 52 Nm
|
||||
angle := round0(100 + rand.Float64()*40) // 100 ~ 140°
|
||||
screwNo := fmt.Sprintf("S%d", rand.IntN(10)+1) // S1 ~ S10
|
||||
result := "OK" // OK 概率 90%
|
||||
if rand.Float64() >= 0.9 {
|
||||
result = "NG"
|
||||
}
|
||||
|
||||
_, err := m.db.InsertTorqueResult(store.TorqueResult{
|
||||
WorkOrderNo: orderNo,
|
||||
Sn: sn,
|
||||
DockCode: m.dockCode,
|
||||
ScrewNo: screwNo,
|
||||
Torque: torque,
|
||||
Angle: angle,
|
||||
Result: result,
|
||||
Operator: "auto", // 终端自动采集
|
||||
})
|
||||
if err != nil {
|
||||
logx.Errorf("模拟拧紧结果入库失败: %v", err)
|
||||
return
|
||||
}
|
||||
logx.Infof("[模拟拧紧] sn=%s 工位=%s 螺丝=%s 扭矩=%.1fNm 角度=%.0f° 结果=%s",
|
||||
sn, m.dockCode, screwNo, torque, angle, result)
|
||||
}
|
||||
|
||||
// fetchRecentTask 从 MES 拉取当前工位最近任务,宽松解析其中的 sn/orderNo 字段,
|
||||
// 任一环节失败均静默返回空串(保证离线时继续生成 MOCK 数据)。
|
||||
func (m *MockSource) fetchRecentTask() (sn string, orderNo string) {
|
||||
stationNo := 0
|
||||
for _, ch := range m.dockCode {
|
||||
if ch < '0' || ch > '9' {
|
||||
stationNo = 0
|
||||
break
|
||||
}
|
||||
stationNo = stationNo*10 + int(ch-'0')
|
||||
}
|
||||
query := url.Values{}
|
||||
if stationNo > 0 {
|
||||
query.Set("stationNo", fmt.Sprintf("%d", stationNo))
|
||||
}
|
||||
body, _, err := m.mes.Get("/api/internal/station/task", query)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
var wrap map[string]any
|
||||
if json.Unmarshal(body, &wrap) != nil {
|
||||
return "", ""
|
||||
}
|
||||
data, ok := wrap["data"].(map[string]any)
|
||||
if !ok {
|
||||
data = wrap // 兼容裸 JSON 响应
|
||||
}
|
||||
s, _ := data["sn"].(string)
|
||||
o, _ := data["orderNo"].(string)
|
||||
return s, o
|
||||
}
|
||||
|
||||
func round1(v float64) float64 { return math.Round(v*10) / 10 }
|
||||
func round0(v float64) float64 { return math.Round(v) }
|
||||
|
||||
// NetSource 丹尼科尔拧紧枪网络数据源占位。
|
||||
// TODO 正式协议暂放:待拿到丹尼科尔协议文档后在此实现 TCP/UDP 报文解析。
|
||||
// TODO 正式协议暂放:待拿到丹尼科尔协议文档后在此实现 TCP/UDP 报文解析,
|
||||
// 解析出扭矩/角度后同样按当前工件的拧紧工艺步骤写入本地库(step_id 关联)。
|
||||
type NetSource struct{}
|
||||
|
||||
var _ Source = (*NetSource)(nil)
|
||||
|
||||
func (n *NetSource) Start(ctx context.Context) {
|
||||
logx.Info("拧紧枪网络数据源(占位)已启动,等待丹尼科尔协议接入")
|
||||
<-ctx.Done()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user