1. 全局替换"工序"为"工艺"统一术语,包括页面文案、枚举、注释
2. 重构工单与工件工艺数据模型:
- 新增工艺组合表flow_material作为备料/齐套唯一源头
- 新增工位状态表station_state支持自主停单/恢复接单
- 移除station_process派工表,改用工单工艺组合作为路线唯一源头
- 替换processCode为flowId作为工艺关联标识
- 重构工件当前进度字段为currentStationNo
3. 删除冗余的静态备份资源文件
4. 调整物料选择组件默认启用仅显示激活物料
5. 优化工单创建/编辑逻辑,新增工艺组合校验与保存
701 lines
25 KiB
Go
701 lines
25 KiB
Go
package logic
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"bj_power_mes/ent"
|
||
"bj_power_mes/ent/flowmaterial"
|
||
"bj_power_mes/ent/processflow"
|
||
"bj_power_mes/ent/processstep"
|
||
"bj_power_mes/ent/station"
|
||
"bj_power_mes/ent/stepcriterion"
|
||
"bj_power_mes/ent/workorder"
|
||
"bj_power_mes/ent/workpieceprocess"
|
||
)
|
||
|
||
// ---------- 工艺流程(块3) ----------
|
||
|
||
// FlowReq 工艺流程保存请求(流程=菜谱,只描述"怎么干")
|
||
// Stations:绑定工位列表(可多选,一个流程可绑多个工位)。
|
||
type FlowReq struct {
|
||
Id int `json:"id"`
|
||
Name string `json:"name"`
|
||
Stations []int `json:"stations"`
|
||
PdfFile string `json:"pdfFile"`
|
||
Status string `json:"status"`
|
||
Remark string `json:"remark"`
|
||
Steps []StepItemReq `json:"steps"`
|
||
}
|
||
|
||
// FlowVO 工艺流程视图(含工序步骤 + 绑定该流程的工位号)
|
||
type FlowVO struct {
|
||
Id int `json:"id"`
|
||
Name string `json:"name"`
|
||
PdfFile string `json:"pdfFile"`
|
||
Status string `json:"status"`
|
||
Remark string `json:"remark"`
|
||
Stations []int `json:"stations"`
|
||
Steps []*ProcessStepTemplate `json:"steps"`
|
||
CreatedAt time.Time `json:"createdAt"`
|
||
}
|
||
|
||
// ListFlows 查询工艺流程(可按工位号过滤:只认「关联工位」页的绑定关系 station.flow_id)
|
||
// page>0 时真分页返回 map{total,list,page,pageSize}(管理页用);page=0 全量数组(工位终端兼容)
|
||
func (s *Service) ListFlows(ctx context.Context, stationNo, page, pageSize int) (any, error) {
|
||
q := s.ctx.EntClient.ProcessFlow.Query().
|
||
Order(ent.Asc(processflow.FieldID))
|
||
flows, err := q.All(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
stations, _ := s.ctx.EntClient.Station.Query().All(ctx)
|
||
flowToStations := map[int][]int{}
|
||
for _, st := range stations {
|
||
if st.FlowId > 0 {
|
||
flowToStations[st.FlowId] = append(flowToStations[st.FlowId], st.StationNo)
|
||
}
|
||
}
|
||
out := make([]*FlowVO, 0, len(flows))
|
||
for _, f := range flows {
|
||
// 过滤:指定工位号时,只返回绑定了该工位的流程
|
||
if stationNo > 0 {
|
||
hit := false
|
||
for _, st := range flowToStations[f.ID] {
|
||
if st == stationNo {
|
||
hit = true
|
||
break
|
||
}
|
||
}
|
||
if !hit {
|
||
continue
|
||
}
|
||
}
|
||
vo := &FlowVO{
|
||
Id: f.ID, Name: f.Name,
|
||
PdfFile: f.PdfFile, Status: f.Status, Remark: f.Remark,
|
||
Stations: []int{}, Steps: s.flowSteps(ctx, f.ID), CreatedAt: f.CreatedAt,
|
||
}
|
||
if list, ok := flowToStations[f.ID]; ok {
|
||
vo.Stations = list
|
||
}
|
||
out = append(out, vo)
|
||
}
|
||
if page > 0 {
|
||
total := len(out)
|
||
start := (page - 1) * pageSize
|
||
if start > total {
|
||
start = total
|
||
}
|
||
end := start + pageSize
|
||
if end > total {
|
||
end = total
|
||
}
|
||
return map[string]any{"total": total, "list": out[start:end], "page": page, "pageSize": pageSize}, nil
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// SaveFlow 保存工艺流程(可一次绑定多个工位)。
|
||
// 约束:① 绑定工位必须已存在(工位主数据由数据库维护,本接口不自动创建);
|
||
// ② 一个工位同时只能绑定一个流程(保存时直接覆盖旧绑定);
|
||
// ③ 停用流程不再自动解绑工位(绑定关系由本保存接口显式维护,启停只切流程状态)。
|
||
func (s *Service) SaveFlow(ctx context.Context, req FlowReq, operator string) error {
|
||
if req.Name == "" {
|
||
return errors.New("流程名称必填")
|
||
}
|
||
// 解析绑定工位列表
|
||
seen := map[int]bool{}
|
||
stations := []int{}
|
||
for _, no := range req.Stations {
|
||
if no < 1 || seen[no] {
|
||
continue
|
||
}
|
||
seen[no] = true
|
||
stations = append(stations, no)
|
||
}
|
||
status := req.Status
|
||
if status == "" {
|
||
status = "ACTIVE"
|
||
}
|
||
if status != "ACTIVE" && status != "INACTIVE" {
|
||
return errors.New("非法状态")
|
||
}
|
||
// 绑定语义(2026-09-08 定稿):直接更换——一个工位只有一条关联数据,
|
||
// 保存时所选工位一律改绑到本流程(覆盖旧绑定),不再报"已被启用流程绑定"冲突。
|
||
var flowID int
|
||
if req.Id > 0 {
|
||
upd := s.ctx.EntClient.ProcessFlow.UpdateOneID(req.Id).
|
||
SetName(req.Name).SetStatus(status).SetRemark(req.Remark)
|
||
if req.PdfFile != "" {
|
||
upd.SetPdfFile(req.PdfFile)
|
||
}
|
||
if _, err := upd.Save(ctx); err != nil {
|
||
return err
|
||
}
|
||
flowID = req.Id
|
||
} else {
|
||
f, err := s.ctx.EntClient.ProcessFlow.Create().
|
||
SetName(req.Name).SetPdfFile(req.PdfFile).
|
||
SetStatus(status).SetRemark(req.Remark).Save(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
flowID = f.ID
|
||
}
|
||
// 绑定同步:把绑定列表整体指向该流程(旧绑定该流程但不在列表内的工位解绑)
|
||
boundNow, _ := s.ctx.EntClient.Station.Query().Where(station.FlowId(flowID)).All(ctx)
|
||
for _, st := range boundNow {
|
||
if !seen[st.StationNo] {
|
||
_, _ = s.ctx.EntClient.Station.UpdateOneID(st.ID).SetFlowId(0).Save(ctx)
|
||
}
|
||
}
|
||
for _, no := range stations {
|
||
if err := s.syncStationFlow(ctx, no, flowID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// 覆盖式重建该流程的工序步骤及考核标准
|
||
_, _ = s.ctx.EntClient.ProcessStep.Delete().Where(processstep.FlowId(flowID)).Exec(ctx)
|
||
for _, st := range req.Steps {
|
||
if st.Name == "" {
|
||
continue
|
||
}
|
||
rec, err := s.ctx.EntClient.ProcessStep.Create().
|
||
SetFlowId(flowID).SetSeq(st.Seq).SetName(st.Name).
|
||
SetCollectType(st.CollectType).SetIsTorque(st.IsTorque).SetNeedCheck(st.NeedCheck).
|
||
SetRemark(st.Remark).SetAttachment(st.Attachment).Save(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, _ = s.ctx.EntClient.StepCriterion.Delete().Where(stepcriterion.StepId(rec.ID)).Exec(ctx)
|
||
for _, c := range st.Criteria {
|
||
if c.Name == "" || c.Logic == "" || c.Logic == "NONE" {
|
||
continue
|
||
}
|
||
b := s.ctx.EntClient.StepCriterion.Create().
|
||
SetStepId(rec.ID).SetName(c.Name).SetUnit(c.Unit).SetLogic(c.Logic).SetTarget(c.Target)
|
||
if c.Min != nil {
|
||
b = b.SetMin(*c.Min)
|
||
}
|
||
if c.Max != nil {
|
||
b = b.SetMax(*c.Max)
|
||
}
|
||
if err := b.Exec(ctx); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
s.ctx.EventLog.Write(ctx, "process.flow.save", "", operator, "process_flow", "", "维护工艺流程",
|
||
map[string]any{"flowId": flowID, "stations": stations, "steps": len(req.Steps)})
|
||
return nil
|
||
}
|
||
|
||
// syncStationFlow 同步工位绑定:工位 stationNo 绑定到 flowID(工位不存在时报错,不自动创建)
|
||
func (s *Service) syncStationFlow(ctx context.Context, stationNo, flowID int) error {
|
||
st, err := s.ctx.EntClient.Station.Query().Where(station.StationNo(stationNo)).First(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("工位 %d 不存在,请先在工位主数据中维护", stationNo)
|
||
}
|
||
_, err = s.ctx.EntClient.Station.UpdateOneID(st.ID).SetFlowId(flowID).Save(ctx)
|
||
return err
|
||
}
|
||
|
||
func (s *Service) DeleteFlow(ctx context.Context, id int, operator string) error {
|
||
// 删除前先解除所有工位绑定,再清步骤与流程本身
|
||
_, _ = s.ctx.EntClient.Station.Update().
|
||
Where(station.FlowId(id)).
|
||
SetFlowId(0).Save(ctx)
|
||
_, _ = s.ctx.EntClient.ProcessStep.Delete().Where(processstep.FlowId(id)).Exec(ctx)
|
||
s.ctx.EventLog.Write(ctx, "process.flow.delete", "", operator, "process_flow", "", "删除工艺流程", map[string]any{"flowId": id})
|
||
return s.ctx.EntClient.ProcessFlow.DeleteOneID(id).Exec(ctx)
|
||
}
|
||
|
||
// flowSteps 查询某流程的工序步骤(含考核标准)
|
||
func (s *Service) flowSteps(ctx context.Context, flowId int) []*ProcessStepTemplate {
|
||
steps, _ := s.ctx.EntClient.ProcessStep.Query().
|
||
Where(processstep.FlowId(flowId)).Order(ent.Asc(processstep.FieldSeq)).All(ctx)
|
||
out := make([]*ProcessStepTemplate, 0, len(steps))
|
||
for _, st := range steps {
|
||
t := &ProcessStepTemplate{
|
||
ID: st.ID, Seq: st.Seq,
|
||
Name: st.Name, CollectType: st.CollectType, IsTorque: st.IsTorque, NeedCheck: st.NeedCheck,
|
||
Remark: st.Remark, Attachment: st.Attachment, Criteria: []CriterionVO{},
|
||
}
|
||
crits, _ := s.ctx.EntClient.StepCriterion.Query().
|
||
Where(stepcriterion.StepId(st.ID)).All(ctx)
|
||
for _, c := range crits {
|
||
t.Criteria = append(t.Criteria, CriterionVO{
|
||
ID: c.ID, Name: c.Name, Unit: c.Unit, Logic: c.Logic,
|
||
Target: c.Target, Min: c.Min, Max: c.Max,
|
||
})
|
||
}
|
||
out = append(out, t)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ---------- 工位主数据(块3) ----------
|
||
|
||
type StationReq struct {
|
||
Id int `json:"id"`
|
||
StationNo int `json:"stationNo"`
|
||
Name string `json:"name"`
|
||
StationType *string `json:"stationType"`
|
||
FlowId *int `json:"flowId"`
|
||
}
|
||
|
||
type StationVO struct {
|
||
Id int `json:"id"`
|
||
StationNo int `json:"stationNo"`
|
||
Name string `json:"name"`
|
||
FlowId int `json:"flowId"`
|
||
FlowName string `json:"flowName"`
|
||
StationType string `json:"stationType"`
|
||
Status string `json:"status"`
|
||
IsBuiltin bool `json:"isBuiltin"`
|
||
HasDock bool `json:"hasDock"`
|
||
DockCode string `json:"dockCode"`
|
||
}
|
||
|
||
func (s *Service) ListStations(ctx context.Context) ([]*StationVO, error) {
|
||
stas, err := s.ctx.EntClient.Station.Query().Order(ent.Asc(station.FieldStationNo)).All(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
flows, _ := s.ctx.EntClient.ProcessFlow.Query().All(ctx)
|
||
flowMap := map[int]*ent.ProcessFlow{}
|
||
for _, f := range flows {
|
||
flowMap[f.ID] = f
|
||
}
|
||
out := make([]*StationVO, 0, len(stas))
|
||
for _, st := range stas {
|
||
vo := &StationVO{Id: st.ID, StationNo: st.StationNo, Name: st.Name, FlowId: st.FlowId, StationType: st.StationType, Status: st.Status, IsBuiltin: st.IsBuiltin, HasDock: st.HasDock, DockCode: st.DockCode}
|
||
if f, ok := flowMap[st.FlowId]; ok {
|
||
vo.FlowName = f.Name
|
||
}
|
||
out = append(out, vo)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// SaveStation 维护工位:
|
||
// - 工位号已存在 → 只改名称/类型/绑定流程(不增删工位)
|
||
// - 工位号不存在 → 新增工位;新增的工位号由用户自定义,允许扩展 14、15…(虚拟/物理均可)
|
||
//
|
||
// 工位数量以数据库为准,前端下拉随 station 表自动增减;虚拟工位(0/13/14...)不连 PLC、仅记录。
|
||
// 内置工位(种子/初始化数据,is_builtin=true)需连 PLC 并必须按工位号顺序流转,
|
||
// 不允许手动更改工位类型;页面新增的工位一律为非内置,可改类型、可删除。
|
||
// 「是否有接驳台」对应实体位置,由内置数据给定,不接收页面入参。
|
||
// 接驳台↔工位 1:1 绑定为系统预置只读主数据,由种子初始化,页面不可更改、也不接收页面入参。
|
||
func (s *Service) SaveStation(ctx context.Context, req StationReq, operator string) error {
|
||
if req.StationNo < 0 {
|
||
return errors.New("工位号非法(需 ≥ 0)")
|
||
}
|
||
if req.StationNo == 0 && req.Name == "" {
|
||
return errors.New("工位号非法")
|
||
}
|
||
st, err := s.ctx.EntClient.Station.Query().Where(station.StationNo(req.StationNo)).First(ctx)
|
||
if err != nil {
|
||
// 新增工位:工位号不存在即创建
|
||
if req.Name == "" {
|
||
return errors.New("请填写工位名称")
|
||
}
|
||
stype := "LINE"
|
||
if req.StationType != nil {
|
||
stype = *req.StationType
|
||
if stype != "LINE" && stype != "OFFLINE" {
|
||
return errors.New("工位类型仅支持 LINE/OFFLINE")
|
||
}
|
||
}
|
||
cr := s.ctx.EntClient.Station.Create().
|
||
SetStationNo(req.StationNo).SetName(req.Name).SetStationType(stype).SetStatus("ENABLED").
|
||
// 页面「新增工位」创建的是非内置工位(可改类型、可删除);内置工位只能由种子/初始化数据产生。
|
||
// 是否有接驳台对应实体位置,由内置数据给定,页面新增时一律为 false 且不可手改。
|
||
SetIsBuiltin(false).SetHasDock(false)
|
||
if req.FlowId != nil && *req.FlowId > 0 {
|
||
flow, fErr := s.ctx.EntClient.ProcessFlow.Get(ctx, *req.FlowId)
|
||
if fErr != nil {
|
||
return errors.New("所选工艺流程不存在")
|
||
}
|
||
if flow.Status != "ACTIVE" {
|
||
return errors.New("该工艺流程已停用,请先启用后再绑定工位")
|
||
}
|
||
cr.SetFlowId(*req.FlowId)
|
||
}
|
||
created, cErr := cr.Save(ctx)
|
||
if cErr != nil {
|
||
return cErr
|
||
}
|
||
s.ctx.EventLog.Write(ctx, "station.create", "", operator, "station", "",
|
||
"新增工位", map[string]any{"stationNo": created.StationNo, "name": created.Name})
|
||
return nil
|
||
}
|
||
// 仅改名称:flowId 不传(nil)保留原绑定;0=解绑;>0=绑定指定启用流程
|
||
upd := s.ctx.EntClient.Station.UpdateOneID(st.ID).SetName(req.Name)
|
||
if req.StationType != nil {
|
||
t := *req.StationType
|
||
if t != "LINE" && t != "OFFLINE" {
|
||
return errors.New("工位类型仅支持 LINE/OFFLINE")
|
||
}
|
||
// 内置工位对应实体产线固定位置(含 PLC 通讯与顺序流转),工位类型不可手动更改。
|
||
if st.IsBuiltin && t != st.StationType {
|
||
return errors.New("内置工位不允许修改工位类型")
|
||
}
|
||
if !st.IsBuiltin {
|
||
upd.SetStationType(t)
|
||
}
|
||
}
|
||
if req.FlowId != nil {
|
||
fid := *req.FlowId
|
||
if fid > 0 {
|
||
flow, fErr := s.ctx.EntClient.ProcessFlow.Get(ctx, fid)
|
||
if fErr != nil {
|
||
return errors.New("所选工艺流程不存在")
|
||
}
|
||
if flow.Status != "ACTIVE" {
|
||
return errors.New("该工艺流程已停用,请先启用后再绑定工位")
|
||
}
|
||
upd.SetFlowId(fid)
|
||
} else {
|
||
upd.SetFlowId(0)
|
||
}
|
||
}
|
||
if _, err := upd.Save(ctx); err != nil {
|
||
return err
|
||
}
|
||
s.ctx.EventLog.Write(ctx, "station.save", "", operator, "station", "", "维护工位",
|
||
map[string]any{"stationNo": req.StationNo})
|
||
return nil
|
||
}
|
||
|
||
// DeleteStation 删除工位:内置工位不允许删除(对应实体产线位置,需连 PLC 且必须按顺序流转);
|
||
// 非内置工位(页面新增的)允许删除。
|
||
func (s *Service) DeleteStation(ctx context.Context, id int, operator string) error {
|
||
if id <= 0 {
|
||
return errors.New("缺少 id")
|
||
}
|
||
st, err := s.ctx.EntClient.Station.Get(ctx, id)
|
||
if err != nil {
|
||
return errors.New("工位不存在")
|
||
}
|
||
if st.IsBuiltin {
|
||
return errors.New("内置工位不允许删除")
|
||
}
|
||
if err := s.ctx.EntClient.Station.DeleteOneID(st.ID).Exec(ctx); err != nil {
|
||
return err
|
||
}
|
||
s.ctx.EventLog.Write(ctx, "station.delete", "", operator, "station", "", "删除工位",
|
||
map[string]any{"stationNo": st.StationNo, "name": st.Name})
|
||
return nil
|
||
}
|
||
|
||
// SetFlowStatus 工艺流程启用/停用。
|
||
// 新语义:绑定关系由「保存流程」显式维护(一个流程可绑多工位),启停只切换流程状态,
|
||
// 不再自动解绑/绑定工位;停用流程对已绑定工位不可用(工位取步骤时按 ACTIVE 过滤)。
|
||
func (s *Service) SetFlowStatus(ctx context.Context, id int, status, operator string) error {
|
||
if id <= 0 {
|
||
return errors.New("缺少 id")
|
||
}
|
||
if status != "ACTIVE" && status != "INACTIVE" {
|
||
return errors.New("非法状态")
|
||
}
|
||
flow, err := s.ctx.EntClient.ProcessFlow.Get(ctx, id)
|
||
if err != nil {
|
||
return errors.New("工艺流程不存在")
|
||
}
|
||
if flow.Status == status {
|
||
return nil
|
||
}
|
||
// 启用无需额外动作:绑定由保存流程时校验(一个工位不会被两个启用流程占用)
|
||
if err := s.ctx.EntClient.ProcessFlow.UpdateOneID(id).SetStatus(status).Exec(ctx); err != nil {
|
||
return err
|
||
}
|
||
action := "停用工艺流程"
|
||
if status == "ACTIVE" {
|
||
action = "启用工艺流程"
|
||
}
|
||
s.ctx.EventLog.Write(ctx, "process.flow.status", "", operator, "process_flow", "", action, map[string]any{"flowId": id, "status": status, "name": flow.Name})
|
||
return nil
|
||
}
|
||
|
||
// ---------- 工位任务(块4/5:工位终端拉取) ----------
|
||
|
||
func (s *Service) StationTask(ctx context.Context, stationNo int) (map[string]any, error) {
|
||
st, err := s.ctx.EntClient.Station.Query().Where(station.StationNo(stationNo)).First(ctx)
|
||
if err != nil {
|
||
return nil, errors.New("工位不存在或未配置")
|
||
}
|
||
orderNos := []string{}
|
||
wos, _ := s.ctx.EntClient.WorkOrder.Query().
|
||
Where(workorder.StatusIn("CREATED", "RELEASED", "IN_PROGRESS")).
|
||
Order(ent.Asc(workorder.FieldID)).Limit(10).All(ctx)
|
||
for _, w := range wos {
|
||
orderNos = append(orderNos, w.WorkOrderNo)
|
||
}
|
||
// M4:工位终端按「本工位绑定的工艺流程」驱动。
|
||
// 产线定义 = 关联工位(station.flow_id)+ 工位号 1→12 顺序,已无独立"工艺路线/路线段"对象。
|
||
// 本工位要干哪些步骤,完全取决于 station.flow_id 指向的流程图,工单的 processSeq 只决定走哪些工位。
|
||
activeFlowId := st.FlowId
|
||
var flow *ent.ProcessFlow
|
||
if activeFlowId > 0 {
|
||
flow, _ = s.ctx.EntClient.ProcessFlow.Get(ctx, activeFlowId)
|
||
}
|
||
steps := []*ProcessStepTemplate{}
|
||
if flow != nil && flow.Status == "ACTIVE" {
|
||
steps = s.flowSteps(ctx, flow.ID)
|
||
}
|
||
return map[string]any{
|
||
"stationNo": st.StationNo,
|
||
"stationName": st.Name,
|
||
"flowId": activeFlowId,
|
||
"flow": flow,
|
||
"flowActive": flow != nil && flow.Status == "ACTIVE",
|
||
"steps": steps,
|
||
"orderNos": orderNos,
|
||
}, nil
|
||
}
|
||
|
||
// WorkloadRow 绩效/工作量聚合行(按人/按工位/明细三视图共用)
|
||
// 前端按 camelCase 读取,必须显式标注 json tag(否则 Go 默认序列化为 PascalCase 导致列空白)
|
||
type WorkloadRow struct {
|
||
Operator string `json:"operator"`
|
||
StationNo string `json:"stationNo"`
|
||
Date string `json:"date"`
|
||
FlowId int `json:"flowId"`
|
||
ProcessName string `json:"processName"`
|
||
DoneCount int `json:"doneCount"`
|
||
OkCount int `json:"okCount"`
|
||
NgCount int `json:"ngCount"`
|
||
// Item M 绩效效率:作业时长取自 workpiece_process.duration_sec
|
||
TotalDurationSec int `json:"totalDurationSec"` // 累计作业时长(秒)
|
||
AvgDurationSec int `json:"avgDurationSec"` // 平均作业时长(秒)=累计/完成数
|
||
Efficiency float64 `json:"efficiency"` // 效率(件/小时)=完成数/(累计时长/3600)
|
||
}
|
||
|
||
// finalizeWorkloadRow 由累计作业时长与完成数派生平均作业时长与效率
|
||
func finalizeWorkloadRow(r *WorkloadRow) {
|
||
if r.DoneCount > 0 {
|
||
r.AvgDurationSec = r.TotalDurationSec / r.DoneCount
|
||
}
|
||
if r.TotalDurationSec > 0 {
|
||
r.Efficiency = float64(r.DoneCount) / (float64(r.TotalDurationSec) / 3600.0)
|
||
}
|
||
}
|
||
|
||
// Workload 绩效统计(按人/按工位/明细三视图,各自真分页)。
|
||
// orderNo 非空时按工单号模糊过滤(客户第12条「记录查看加一项看工单号查询」)。
|
||
func (s *Service) Workload(ctx context.Context, operator, stationNo, orderNo, from, to string,
|
||
opPage, opSize, stPage, stSize, detPage, detSize int) (map[string]any, error) {
|
||
q := s.ctx.EntClient.WorkpieceProcess.Query()
|
||
if operator != "" {
|
||
q = q.Where(workpieceprocess.Operator(operator))
|
||
}
|
||
if stationNo != "" {
|
||
q = q.Where(workpieceprocess.StationNo(stationNo))
|
||
}
|
||
if orderNo != "" {
|
||
q = q.Where(workpieceprocess.OrderNoContainsFold(orderNo))
|
||
}
|
||
if from != "" {
|
||
if f, err := time.Parse("2006-01-02", from); err == nil {
|
||
q = q.Where(workpieceprocess.CreatedAtGTE(f))
|
||
}
|
||
}
|
||
if to != "" {
|
||
if t, err := time.Parse("2006-01-02", to); err == nil {
|
||
q = q.Where(workpieceprocess.CreatedAtLT(t.Add(24 * time.Hour)))
|
||
}
|
||
}
|
||
rows, err := q.All(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
type key struct {
|
||
op, station, date string
|
||
code int
|
||
}
|
||
agg := map[key]*WorkloadRow{}
|
||
for _, r := range rows {
|
||
k := key{r.Operator, r.StationNo, r.CreatedAt.Format("2006-01-02"), r.FlowId}
|
||
row, ok := agg[k]
|
||
if !ok {
|
||
row = &WorkloadRow{Operator: r.Operator, StationNo: r.StationNo, Date: k.date, FlowId: r.FlowId, ProcessName: r.ProcessName}
|
||
agg[k] = row
|
||
}
|
||
row.DoneCount++
|
||
row.TotalDurationSec += r.DurationSec
|
||
if r.Result == "OK" {
|
||
row.OkCount++
|
||
} else {
|
||
row.NgCount++
|
||
}
|
||
}
|
||
list := make([]*WorkloadRow, 0, len(agg))
|
||
for _, v := range agg {
|
||
list = append(list, v)
|
||
}
|
||
sort.Slice(list, func(i, j int) bool {
|
||
if list[i].Operator != list[j].Operator {
|
||
return list[i].Operator < list[j].Operator
|
||
}
|
||
if list[i].StationNo != list[j].StationNo {
|
||
return list[i].StationNo < list[j].StationNo
|
||
}
|
||
if list[i].Date != list[j].Date {
|
||
return list[i].Date < list[j].Date
|
||
}
|
||
return list[i].FlowId < list[j].FlowId
|
||
})
|
||
|
||
// 三个视图分别聚合(2026-09-08 绩效报表改造:按人/按工位/明细,各自真分页)
|
||
byOpAgg := map[string]*WorkloadRow{}
|
||
byStAgg := map[string]*WorkloadRow{}
|
||
for _, v := range list {
|
||
opKey := v.Operator
|
||
if o, ok := byOpAgg[opKey]; ok {
|
||
o.DoneCount += v.DoneCount
|
||
o.OkCount += v.OkCount
|
||
o.NgCount += v.NgCount
|
||
o.TotalDurationSec += v.TotalDurationSec
|
||
} else {
|
||
byOpAgg[opKey] = &WorkloadRow{Operator: v.Operator, DoneCount: v.DoneCount, OkCount: v.OkCount, NgCount: v.NgCount, TotalDurationSec: v.TotalDurationSec}
|
||
}
|
||
stKey := v.StationNo
|
||
if o, ok := byStAgg[stKey]; ok {
|
||
o.DoneCount += v.DoneCount
|
||
o.OkCount += v.OkCount
|
||
o.NgCount += v.NgCount
|
||
o.TotalDurationSec += v.TotalDurationSec
|
||
} else {
|
||
byStAgg[stKey] = &WorkloadRow{StationNo: v.StationNo, DoneCount: v.DoneCount, OkCount: v.OkCount, NgCount: v.NgCount, TotalDurationSec: v.TotalDurationSec}
|
||
}
|
||
}
|
||
paginate := func(all []*WorkloadRow, page, size int) (int, []*WorkloadRow) {
|
||
total := len(all)
|
||
start := (page - 1) * size
|
||
if start > total {
|
||
start = total
|
||
}
|
||
end := start + size
|
||
if end > total {
|
||
end = total
|
||
}
|
||
return total, all[start:end]
|
||
}
|
||
|
||
byOpList := make([]*WorkloadRow, 0, len(byOpAgg))
|
||
for _, v := range byOpAgg {
|
||
finalizeWorkloadRow(v)
|
||
byOpList = append(byOpList, v)
|
||
}
|
||
sort.Slice(byOpList, func(i, j int) bool { return byOpList[i].Operator < byOpList[j].Operator })
|
||
byStList := make([]*WorkloadRow, 0, len(byStAgg))
|
||
for _, v := range byStAgg {
|
||
finalizeWorkloadRow(v)
|
||
byStList = append(byStList, v)
|
||
}
|
||
sort.Slice(byStList, func(i, j int) bool { return byStList[i].StationNo < byStList[j].StationNo })
|
||
|
||
// 明细视图:按 (操作人,工位,日期,工序) 聚合,与前端明细表列(date/doneCount/okCount/ngCount)对齐
|
||
for _, v := range list {
|
||
finalizeWorkloadRow(v)
|
||
}
|
||
opTotal, byOpPage := paginate(byOpList, opPage, opSize)
|
||
stTotal, byStPage := paginate(byStList, stPage, stSize)
|
||
detTotal, detList := paginate(list, detPage, detSize)
|
||
|
||
return map[string]any{
|
||
"byOperator": map[string]any{"total": opTotal, "list": byOpPage},
|
||
"byStation": map[string]any{"total": stTotal, "list": byStPage},
|
||
"detail": map[string]any{"total": detTotal, "list": detList},
|
||
}, nil
|
||
}
|
||
|
||
// ---------- 工艺物料清单(flow_material) ----------
|
||
|
||
// FlowMaterialReq 工艺物料清单条目(保存为整表覆盖)
|
||
type FlowMaterialReq struct {
|
||
MaterialCode string `json:"materialCode"`
|
||
MaterialName string `json:"materialName"`
|
||
Spec string `json:"spec"`
|
||
Unit string `json:"unit"`
|
||
ManageMode string `json:"manageMode"` // 1 结构件(批次) / 2 电气件(SN)
|
||
UnitQty float64 `json:"unitQty"` // 单台用量
|
||
LossRate float64 `json:"lossRate"` // 损耗率(%)
|
||
}
|
||
|
||
// ListFlowMaterials 查某工艺的物料清单(按 ID 升序)
|
||
func (s *Service) ListFlowMaterials(ctx context.Context, flowId int) ([]*ent.FlowMaterial, error) {
|
||
return s.ctx.EntClient.FlowMaterial.Query().
|
||
Where(flowmaterial.FlowId(flowId)).
|
||
Order(ent.Asc(flowmaterial.FieldID)).All(ctx)
|
||
}
|
||
|
||
// SaveFlowMaterials 保存工艺物料清单(整表覆盖:先删后插)。
|
||
// 物料编码须在 WMS 物料档案存在;WMS 不可达/异常时降级放行并记日志,避免阻塞工艺配置。
|
||
func (s *Service) SaveFlowMaterials(ctx context.Context, flowId int, items []FlowMaterialReq, operator string) error {
|
||
if flowId <= 0 {
|
||
return errors.New("flowId 必填")
|
||
}
|
||
if _, err := s.ctx.EntClient.ProcessFlow.Get(ctx, flowId); err != nil {
|
||
return errors.New("工艺不存在")
|
||
}
|
||
// WMS 物料存在性校验(缺失拒绝;不可达降级放行)
|
||
codes := make([]string, 0, len(items))
|
||
seen := map[string]bool{}
|
||
for _, it := range items {
|
||
if it.MaterialCode == "" {
|
||
return errors.New("物料编码不能为空")
|
||
}
|
||
if seen[it.MaterialCode] {
|
||
return errors.New("物料 " + it.MaterialCode + " 重复")
|
||
}
|
||
seen[it.MaterialCode] = true
|
||
codes = append(codes, it.MaterialCode)
|
||
}
|
||
if len(codes) > 0 && s.ctx.Wms != nil {
|
||
if missing, err := s.ctx.Wms.MaterialExists(ctx, codes); err != nil {
|
||
s.ctx.EventLog.Write(ctx, "flow.material.wms_skip", "", operator, "process_flow", itoa(flowId),
|
||
"WMS 物料校验不可达,已降级放行", map[string]any{"error": err.Error()})
|
||
} else if len(missing) > 0 {
|
||
return errors.New("以下物料在 WMS 物料档案中不存在:" + strings.Join(missing, "、"))
|
||
}
|
||
}
|
||
tx, err := s.ctx.EntClient.Tx(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback()
|
||
}
|
||
}()
|
||
client := tx.Client()
|
||
if _, err := client.FlowMaterial.Delete().Where(flowmaterial.FlowId(flowId)).Exec(ctx); err != nil {
|
||
return err
|
||
}
|
||
for _, it := range items {
|
||
if err := client.FlowMaterial.Create().
|
||
SetFlowId(flowId).
|
||
SetMaterialCode(it.MaterialCode).SetMaterialName(it.MaterialName).
|
||
SetSpec(it.Spec).SetUnit(it.Unit).SetManageMode(it.ManageMode).
|
||
SetUnitQty(it.UnitQty).SetLossRate(it.LossRate).
|
||
Exec(ctx); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return err
|
||
}
|
||
committed = true
|
||
s.ctx.EventLog.Write(ctx, "flow.material.save", "", operator, "process_flow", itoa(flowId),
|
||
"保存工艺物料清单(整表覆盖)", map[string]any{"flowId": flowId, "count": len(items)})
|
||
return nil
|
||
}
|