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}}
| {{.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}} |
{{end}}
二、各工位工艺流程与步骤明细
{{range .Stations}}
{{if .Steps}}
| 工位{{.StationNo}} {{.StationName}} · {{if .FlowName}}{{.FlowName}}{{else}}未绑定流程{{end}} |
| 步骤号 | 步骤名称 | 采集方式 | 检测确认 | 拧紧采集 | 考核标准 |
{{range .Steps}}
| {{.Seq}} | {{.Name}} | {{.CollectType}} |
{{if .NeedCheck}}需检验{{else}}否{{end}} |
{{if .IsTorque}}是{{else}}否{{end}} |
{{.Criteria}} |
{{end}}
{{end}}
{{end}}
三、物料清单
{{if .BomGroups}}
{{range .BomGroups}}
| BOM:{{.BomName}} |
| 图号 | 物料名称 | 规格 | 单位 | 管理方式 | 装配工位 | 单台用量 | 损耗率(%) | 需求总量 |
{{range .Items}}
| {{.MaterialCode}} | {{.MaterialName}} | {{.Spec}} |
{{.Unit}} | {{.ManageMode}} | {{.StationNo}} |
{{.UnitQty}} | {{.LossRate}} | {{.RequiredQty}} |
{{end}}
{{end}}
{{else}}
该产品型号暂无物料清单。
{{end}}
四、检验卡项(所有「检测确认」步骤)
{{if .CheckItems}}
| 工位号 | 工位名称 | 检验步骤 | 判定标准 |
{{range .CheckItems}}
| {{.StationNo}} | {{.StationName}} | {{.StepName}} | {{.Criteria}} |
{{end}}
{{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
}