feat(mes): 添加定时同步可生产数量到WMS及BOM项相关标准字段

- 在main.go中添加定时任务每10分钟同步可生产数量到WMS系统
- 为BomItem实体添加relatedStandard字段及相关CRUD方法
- 为InspectionRecord实体添加reportNo、materialCode、materialName等字段
- 更新ent schema确保新字段的验证和默认值设置
- 添加必要的数据库迁移和字段映射逻辑
This commit is contained in:
SunYF
2026-09-17 12:49:07 +08:00
parent b4b274301d
commit 1cc93795ce
191 changed files with 24504 additions and 1250 deletions
+184 -18
View File
@@ -106,6 +106,25 @@ type ReportProcessReq struct {
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 工位报工:先应用装机绑定并做强校验(齐套才放行),再写报工实绩 + 步骤考核。
@@ -119,52 +138,94 @@ func (s *Service) ReportProcess(ctx context.Context, req ReportProcessReq, opera
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, req.Sn, wp.OrderNo, req.ProcessCode, stationNo, operator, req.Binds); err != nil {
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, req.Sn, req.ProcessCode); err != nil {
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, req); err != nil {
if err := s.validateNeedCheck(ctx, client, req); err != nil {
return err
}
now := time.Now()
proc, err := s.ctx.EntClient.WorkpieceProcess.Create().
// 作业时长(方案 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(now).SetEndedAt(now).Save(ctx)
SetStartedAt(startedAt).SetEndedAt(endedAt).SetDurationSec(durationSec).Save(ctx)
if err != nil {
return err
}
allOK := true
for _, st := range req.Steps {
if ok, e := s.writeStepData(ctx, req.Sn, proc.ID, st, operator); e == nil && !ok {
ok, e := s.writeStepData(ctx, client, req.Sn, proc.ID, st, operator)
if e != nil {
return e
}
if !ok {
allOK = false
}
}
if bad, _ := s.ctx.EntClient.StepData.Query().
Where(stepdata.ProcessId(proc.ID), stepdata.IsOK(false)).Exist(ctx); bad {
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"
}
_, _ = s.ctx.EntClient.WorkpieceProcess.UpdateOneID(proc.ID).SetResult(result).Save(ctx)
_, _ = s.ctx.EntClient.Workpiece.UpdateOneID(wp.ID).
SetCurrentProcess(req.ProcessCode).SetStatus("PROCESSING").Save(ctx)
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})
@@ -176,15 +237,15 @@ func (s *Service) ReportProcess(ctx context.Context, req ReportProcessReq, opera
// 规则:本工位绑定的工艺流程中,凡标记 needCheck=true 的工艺步骤,
// 报工请求里必须存在对应 stepId 且 checked=true 的步骤数据;缺失则拒绝报工。
// 工位未绑定流程、或流程内没有 needCheck 步骤时不拦截(与现场配置保持一致)。
func (s *Service) validateNeedCheck(ctx context.Context, req ReportProcessReq) error {
func (s *Service) validateNeedCheck(ctx context.Context, client *ent.Client, req ReportProcessReq) error {
if req.StationNo <= 0 {
return nil
}
st, err := s.ctx.EntClient.Station.Query().Where(station.StationNo(req.StationNo)).First(ctx)
st, err := client.Station.Query().Where(station.StationNo(req.StationNo)).First(ctx)
if err != nil || st.FlowId <= 0 {
return nil
}
steps, err := s.ctx.EntClient.ProcessStep.Query().
steps, err := client.ProcessStep.Query().
Where(processstep.FlowId(st.FlowId), processstep.NeedCheck(true)).All(ctx)
if err != nil || len(steps) == 0 {
return nil
@@ -212,9 +273,12 @@ func (s *Service) validateNeedCheck(ctx context.Context, req ReportProcessReq) e
}
// writeStepData 写一条步骤考核数据,按标准自动判定是否合格
func (s *Service) writeStepData(ctx context.Context, sn string, processId int, st StepDataReq, operator string) (bool, error) {
func (s *Service) writeStepData(ctx context.Context, client *ent.Client, sn string, processId int, st StepDataReq, operator string) (bool, error) {
ok := true
crits, _ := s.ctx.EntClient.StepCriterion.Query().Where(stepcriterion.StepId(st.StepId)).All(ctx)
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
@@ -227,7 +291,7 @@ func (s *Service) writeStepData(ctx context.Context, sn string, processId int, s
clogic = crits[0].Logic
cid = crits[0].ID
}
_, err := s.ctx.EntClient.StepData.Create().
_, 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).
@@ -249,7 +313,7 @@ func (s *Service) DoneWorkpiece(ctx context.Context, req DoneReq, operator strin
return errors.New("工件不存在")
}
// 兜底强校验:工艺路线中每个工序的装配物料都须绑齐(BOM 未配置工序时自动放行)
if miss, err := s.validateAllProcessBinds(ctx, req.Sn); err != nil {
if miss, err := s.validateAllProcessBinds(ctx, s.ctx.EntClient, req.Sn); err != nil {
return err
} else if miss != "" {
return errors.New("完工前必须绑齐全部装配物料:" + miss)
@@ -337,3 +401,105 @@ func (s *Service) ListWorkpieces(ctx context.Context, sn, orderNo string) ([]*en
}
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
}