package handler import ( "bytes" "context" "fmt" "html/template" "net/http" "sort" "strconv" "strings" "time" "bj_power_mes/common/httpx" "bj_power_mes/ent" "bj_power_mes/ent/bomitem" "bj_power_mes/ent/processflow" "bj_power_mes/ent/processstep" "bj_power_mes/ent/producttype" "bj_power_mes/ent/station" "bj_power_mes/ent/stepcriterion" "bj_power_mes/ent/workorder" "bj_power_mes/ent/workpieceprocess" "bj_power_mes/internal/svc" ) // ModelProcessCardHandler GET /api/v1/process-card/model?productTypeId=&productCode=[&format=json] // // 产品型号级「全景流程卡」:与 SN 级流程卡(按单个工件打印实绩)互补,本卡按**产品型号**聚合 // 该型号的完整制造定义,用于工艺交底/审核/客户查阅: // // 产品型号 → 工位组合 → 每工位工艺流程与步骤(含采集方式/检测点/拧紧/考核标准/工艺图纸) // → 物料清单(按 BOM 分组,含装配工位/单台用量/损耗/需求总量) // → 检验卡项(所有"检测确认"步骤汇总为检验项清单) // → 各环节时间(工单计划时间 + 该型号历史实绩的首末作业时间) // // 默认返回可打印 HTML(浏览器直接打印/另存 PDF);?format=json 返回结构化数据供页面预览。 func ModelProcessCardHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() productTypeId := atoiDefault(q.Get("productTypeId"), 0) productCode := strings.TrimSpace(q.Get("productCode")) if productTypeId <= 0 && productCode == "" { httpx.BadRequest(w, "请指定产品型号(productTypeId 或 productCode)") return } card, err := buildModelCard(r.Context(), svcCtx, productTypeId, productCode) if err != nil { httpx.Fail(w, 2403, err.Error()) return } if q.Get("format") == "json" { httpx.Ok(w, card) return } html, err := renderModelCard(card) if err != nil { httpx.Fail(w, 2404, "全景流程卡生成失败:"+err.Error()) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(html)) } } // ---------- 视图数据结构 ---------- type modelCard struct { ProductName string ProductCode string Category string ProcessSeq string SeqSource string StationCount int OrderNo string PlanStart string PlanEnd string GeneratedAt string Stations []modelStation BomGroups []modelBomGroup CheckItems []modelCheckItem Summary string } type modelStation struct { StationNo int StationName string FlowName string FlowStatus string PdfFile string StepCount int CheckCount int TorqueCount int RefTime string Steps []modelStep } type modelStep struct { Seq int Name string CollectType string NeedCheck bool IsTorque bool Criteria string } type modelBomGroup struct { BomName string Items []modelBomItem } type modelBomItem struct { MaterialCode string MaterialName string Spec string Unit string ManageMode string StationNo string UnitQty string LossRate string RequiredQty string } type modelCheckItem struct { StationNo int StationName string StepName string Criteria string } // ---------- 数据聚合 ---------- func buildModelCard(ctx context.Context, svcCtx *svc.ServiceContext, productTypeId int, productCode string) (*modelCard, error) { client := svcCtx.EntClient card := &modelCard{GeneratedAt: nowText()} // 1) 定位产品型号 var pt *ent.ProductType if productTypeId > 0 { pt, _ = client.ProductType.Get(ctx, productTypeId) } if pt == nil && productCode != "" { pt, _ = client.ProductType.Query().Where(producttype.Code(productCode)).First(ctx) } switch { case pt != nil: card.ProductName = pt.Name card.ProductCode = pt.Code card.Category = pt.Category if productCode == "" { productCode = pt.Code } case productCode != "": card.ProductCode = productCode card.ProductName = productCode default: return nil, fmt.Errorf("产品型号不存在") } // 2) 工位组合:优先取该产品最近一张工单的工位组合;无工单则取全产线工位(升序) seq := []int{} if productCode != "" { if wo, err := client.WorkOrder.Query(). Where(workorder.ProductCode(productCode)). Order(ent.Desc(workorder.FieldID)).First(ctx); err == nil && wo != nil { card.OrderNo = wo.WorkOrderNo if wo.PlanStart != nil { card.PlanStart = wo.PlanStart.Format("2006-01-02 15:04") } if wo.PlanEnd != nil { card.PlanEnd = wo.PlanEnd.Format("2006-01-02 15:04") } seq = parseCardSeq(wo.ProcessSeq) card.SeqSource = "取自工单 " + wo.WorkOrderNo } } if len(seq) == 0 { sts, _ := client.Station.Query().Order(ent.Asc(station.FieldStationNo)).All(ctx) for _, st := range sts { seq = append(seq, st.StationNo) } card.SeqSource = "该型号暂无工单,按全产线工位顺序展示" } sort.Ints(seq) card.ProcessSeq = joinInts(seq) card.StationCount = len(seq) // 3) 工位 + 工艺流程 + 步骤 + 考核标准 sts, _ := client.Station.Query().Where(station.StationNoIn(seq...)).All(ctx) stationByName := map[int]*ent.Station{} flowIds := []int{} for _, st := range sts { stationByName[st.StationNo] = st if st.FlowId > 0 { flowIds = append(flowIds, st.FlowId) } } flows := map[int]*ent.ProcessFlow{} if len(flowIds) > 0 { fl, _ := client.ProcessFlow.Query().Where(processflow.IDIn(flowIds...)).All(ctx) for _, f := range fl { flows[f.ID] = f } } stepsByFlow := map[int][]*ent.ProcessStep{} if len(flowIds) > 0 { allSteps, _ := client.ProcessStep.Query(). Where(processstep.FlowIdIn(flowIds...)). Order(ent.Asc(processstep.FieldFlowId), ent.Asc(processstep.FieldSeq)).All(ctx) for _, s := range allSteps { stepsByFlow[s.FlowId] = append(stepsByFlow[s.FlowId], s) } } critByStep := map[int][]*ent.StepCriterion{} if n, _ := client.StepCriterion.Query().Count(ctx); n > 0 { allCrit, _ := client.StepCriterion.Query().Order(ent.Asc(stepcriterion.FieldStepId)).All(ctx) for _, c := range allCrit { critByStep[c.StepId] = append(critByStep[c.StepId], c) } } // 该型号历史实绩时间(按工位聚合首末作业时间),供"各环节时间"列 refTimes := stationRefTimes(ctx, client, productCode) for _, no := range seq { st := stationByName[no] ms := modelStation{StationNo: no, StationName: fmt.Sprintf("工位%d", no), RefTime: refTimes[no]} if st != nil { if st.Name != "" { ms.StationName = st.Name } if f := flows[st.FlowId]; f != nil { ms.FlowName = f.Name ms.FlowStatus = flowStatusCN(f.Status) ms.PdfFile = f.PdfFile } for _, s := range stepsByFlow[st.FlowId] { crit := criteriaText(critByStep[s.ID]) ms.Steps = append(ms.Steps, modelStep{ Seq: s.Seq, Name: s.Name, CollectType: collectTypeCN(s.CollectType), NeedCheck: s.NeedCheck, IsTorque: s.IsTorque, Criteria: crit, }) if s.NeedCheck { ms.CheckCount++ card.CheckItems = append(card.CheckItems, modelCheckItem{ StationNo: no, StationName: ms.StationName, StepName: s.Name, Criteria: crit, }) } if s.IsTorque { ms.TorqueCount++ } } ms.StepCount = len(ms.Steps) } card.Stations = append(card.Stations, ms) } // 4) 物料清单(按 BOM 名称分组) if productCode != "" { items, _ := client.BomItem.Query(). Where(bomitem.ProductCode(productCode)). Order(ent.Asc(bomitem.FieldBomName), ent.Asc(bomitem.FieldMaterialCode)).All(ctx) groupIdx := map[string]int{} for _, it := range items { name := it.BomName if name == "" { name = "默认" } gi, ok := groupIdx[name] if !ok { card.BomGroups = append(card.BomGroups, modelBomGroup{BomName: name}) gi = len(card.BomGroups) - 1 groupIdx[name] = gi } stationNo := "-" if it.ProcessCode > 0 { stationNo = fmt.Sprintf("工位%d", it.ProcessCode) } card.BomGroups[gi].Items = append(card.BomGroups[gi].Items, modelBomItem{ MaterialCode: it.MaterialCode, MaterialName: it.MaterialName, Spec: it.Spec, Unit: it.Unit, ManageMode: manageModeCN(it.ManageMode), StationNo: stationNo, UnitQty: f2s(it.UnitQty), LossRate: f2s(it.LossRate), RequiredQty: f2s(it.RequiredQty), }) } } card.Summary = fmt.Sprintf("共 %d 个工位、%d 项检验卡项、%d 份物料清单", card.StationCount, len(card.CheckItems), len(card.BomGroups)) return card, nil } // stationRefTimes 该产品型号历史实绩按工位聚合的首末作业时间(无实绩则返回空 map) func stationRefTimes(ctx context.Context, client *ent.Client, productCode string) map[int]string { out := map[int]string{} if productCode == "" { return out } wos, _ := client.WorkOrder.Query().Where(workorder.ProductCode(productCode)). Order(ent.Desc(workorder.FieldID)).Limit(50).All(ctx) if len(wos) == 0 { return out } orderNos := make([]string, 0, len(wos)) for _, wo := range wos { orderNos = append(orderNos, wo.WorkOrderNo) } procs, _ := client.WorkpieceProcess.Query(). Where(workpieceprocess.OrderNoIn(orderNos...)).All(ctx) type span struct { first, last int64 seen bool } m := map[int]*span{} for _, p := range procs { s := m[p.ProcessCode] if s == nil { s = &span{} m[p.ProcessCode] = s } if p.StartedAt != nil { t := p.StartedAt.Unix() if !s.seen || t < s.first { s.first = t } s.seen = true } if p.EndedAt != nil { t := p.EndedAt.Unix() if t > s.last { s.last = t } s.seen = true } } for code, s := range m { if !s.seen { continue } if s.last == 0 { out[code] = tsText(s.first) + " 起(未完成)" continue } out[code] = tsText(s.first) + " → " + tsText(s.last) } return out } // ---------- 文案/格式化助手 ---------- func nowText() string { return time.Now().Format("2006-01-02 15:04:05") } func tsText(unix int64) string { return time.Unix(unix, 0).Format("2006-01-02 15:04") } func collectTypeCN(v string) string { switch v { case "MANUAL": return "手工录入" case "AUTO": return "自动采集" case "NONE", "": return "无需采集" } return v } func flowStatusCN(v string) string { switch v { case "ACTIVE": return "启用" case "INACTIVE": return "停用" } return v } func manageModeCN(v string) string { switch v { case "1": return "批次(结构件)" case "2": return "序列号(电气件)" } return v } func criteriaText(cs []*ent.StepCriterion) string { if len(cs) == 0 { return "-" } parts := make([]string, 0, len(cs)) for _, c := range cs { unit := c.Unit switch c.Logic { case "RANGE": lo, hi := "", "" if c.Min != nil { lo = f2s(*c.Min) } if c.Max != nil { hi = f2s(*c.Max) } parts = append(parts, fmt.Sprintf("%s ∈ [%s, %s]%s", c.Name, lo, hi, unit)) case "GE": parts = append(parts, fmt.Sprintf("%s ≥ %s%s", c.Name, f2s(c.Target), unit)) case "LE": parts = append(parts, fmt.Sprintf("%s ≤ %s%s", c.Name, f2s(c.Target), unit)) case "GT": parts = append(parts, fmt.Sprintf("%s > %s%s", c.Name, f2s(c.Target), unit)) case "LT": parts = append(parts, fmt.Sprintf("%s < %s%s", c.Name, f2s(c.Target), unit)) case "EQUAL": parts = append(parts, fmt.Sprintf("%s = %s%s", c.Name, f2s(c.Target), unit)) default: parts = append(parts, c.Name) } } return strings.Join(parts, ";") } func f2s(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) } func joinInts(a []int) string { p := make([]string, 0, len(a)) for _, v := range a { p = append(p, strconv.Itoa(v)) } return strings.Join(p, ",") } // ---------- HTML 渲染 ---------- func renderModelCard(c *modelCard) (string, error) { const tpl = ` 产品型号全景流程卡 {{.ProductCode}}

产品型号全景流程卡

产品名称:{{.ProductName}} 产品编号:{{.ProductCode}} {{if .Category}}产品分类:{{.Category}}{{end}} 工位组合:{{.ProcessSeq}} 工位数:{{.StationCount}}
工位组合来源:{{.SeqSource}};{{.Summary}};生成时间 {{.GeneratedAt}}
{{if .OrderNo}}
参考工单:{{.OrderNo}}{{if .PlanStart}} 计划开始 {{.PlanStart}}{{end}}{{if .PlanEnd}} 计划结束 {{.PlanEnd}}{{end}}
{{end}}

一、工位流程总览

{{range .Stations}} {{end}}
工位号工位名称工艺流程流程状态步骤数检验点拧紧步骤工艺图纸作业时间(本型号实绩)
{{.StationNo}}{{.StationName}} {{if .FlowName}}{{.FlowName}}{{else}}未绑定流程{{end}} {{if .FlowStatus}}{{.FlowStatus}}{{else}}-{{end}} {{.StepCount}} {{if .CheckCount}}{{.CheckCount}}{{else}}0{{end}} {{.TorqueCount}} {{if .PdfFile}}{{.PdfFile}}{{else}}-{{end}} {{if .RefTime}}{{.RefTime}}{{else}}暂无实绩{{end}}

二、各工位工艺流程与步骤明细

{{range .Stations}} {{if .Steps}} {{range .Steps}} {{end}}
工位{{.StationNo}} {{.StationName}} · {{if .FlowName}}{{.FlowName}}{{else}}未绑定流程{{end}}
步骤号步骤名称采集方式检测确认拧紧采集考核标准
{{.Seq}}{{.Name}}{{.CollectType}} {{if .NeedCheck}}需检验{{else}}否{{end}} {{if .IsTorque}}{{else}}否{{end}} {{.Criteria}}
{{end}} {{end}}

三、物料清单

{{if .BomGroups}} {{range .BomGroups}} {{range .Items}} {{end}}
BOM:{{.BomName}}
图号物料名称规格单位管理方式装配工位单台用量损耗率(%)需求总量
{{.MaterialCode}}{{.MaterialName}}{{.Spec}} {{.Unit}}{{.ManageMode}}{{.StationNo}} {{.UnitQty}}{{.LossRate}}{{.RequiredQty}}
{{end}} {{else}}

该产品型号暂无物料清单。

{{end}}

四、检验卡项(所有「检测确认」步骤)

{{if .CheckItems}} {{range .CheckItems}} {{end}}
工位号工位名称检验步骤判定标准
{{.StationNo}}{{.StationName}}{{.StepName}}{{.Criteria}}
{{else}}

该产品型号暂无检验卡项(工艺流程未设置「需要检测确认」的步骤)。

{{end}}
编制:
审核:
批准:
` tmpl, err := template.New("modelcard").Parse(tpl) if err != nil { return "", err } var buf bytes.Buffer if err := tmpl.Execute(&buf, c); err != nil { return "", err } return buf.String(), nil }