Files
bj_power/bj_power_mes/internal/handler/processcard_model.go
T

587 lines
18 KiB
Go
Raw Normal View History

2026-09-15 16:37:39 +08:00
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 = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<title>产品型号全景流程卡 {{.ProductCode}}</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:"Microsoft YaHei",sans-serif; color:#1f2937; padding:24px; background:#f7f8fa; }
.card { max-width:1080px; margin:0 auto; border:2px solid #333; border-radius:8px; padding:22px 26px; background:#fff; }
h1 { font-size:23px; text-align:center; border-bottom:2px solid #333; padding-bottom:10px; margin-bottom:14px; letter-spacing:5px; }
h2 { font-size:15px; margin:20px 0 8px; border-left:4px solid #1f2937; padding-left:8px; }
.meta { display:flex; flex-wrap:wrap; gap:8px 30px; font-size:14px; margin-bottom:6px; }
.meta b { font-weight:700; }
.hint { font-size:12.5px; color:#6b7280; margin-bottom:4px; }
table { width:100%; border-collapse:collapse; font-size:13px; margin-bottom:4px; }
th, td { border:1px solid #9ca3af; padding:5px 7px; text-align:center; }
th { background:#f3f4f6; }
td.left { text-align:left; }
.ok { color:#15803d; font-weight:700; }
.off { color:#9ca3af; }
.tag { display:inline-block; padding:1px 6px; border-radius:3px; font-size:11.5px; border:1px solid #d1d5db; background:#fafafa; }
.tag.warn { border-color:#f59e0b; color:#b45309; background:#fffbeb; }
.st-name { background:#eef2ff; font-weight:700; text-align:left; }
.sign { display:flex; justify-content:space-between; margin-top:26px; font-size:14px; }
.sign div { width:31%; border-top:1px solid #333; padding-top:6px; text-align:center; }
.bar { text-align:center; margin-top:16px; }
.bar button { padding:8px 30px; font-size:15px; cursor:pointer; }
@media print { body { padding:0; background:#fff; } .card { border-color:#000; border-radius:0; max-width:none; } .no-print { display:none; } }
</style>
</head>
<body>
<div class="card">
<h1>产品型号全景流程卡</h1>
<div class="meta">
<span>产品名称:<b>{{.ProductName}}</b></span>
<span>产品编号:<b>{{.ProductCode}}</b></span>
{{if .Category}}<span>产品分类:<b>{{.Category}}</b></span>{{end}}
<span>工位组合:<b>{{.ProcessSeq}}</b></span>
<span>工位数:<b>{{.StationCount}}</b></span>
</div>
<div class="hint">工位组合来源:{{.SeqSource}}{{.Summary}};生成时间 {{.GeneratedAt}}</div>
{{if .OrderNo}}<div class="hint">参考工单:{{.OrderNo}}{{if .PlanStart}} 计划开始 {{.PlanStart}}{{end}}{{if .PlanEnd}} 计划结束 {{.PlanEnd}}{{end}}</div>{{end}}
<h2>一、工位流程总览</h2>
<table>
<thead><tr><th>工位号</th><th>工位名称</th><th>工艺流程</th><th>流程状态</th><th>步骤数</th><th>检验点</th><th>拧紧步骤</th><th>工艺图纸</th><th>作业时间(本型号实绩)</th></tr></thead>
<tbody>
{{range .Stations}}
<tr>
<td>{{.StationNo}}</td><td class="left">{{.StationName}}</td>
<td class="left">{{if .FlowName}}{{.FlowName}}{{else}}<span class="off">未绑定流程</span>{{end}}</td>
<td>{{if .FlowStatus}}{{.FlowStatus}}{{else}}<span class="off">-</span>{{end}}</td>
<td>{{.StepCount}}</td>
<td>{{if .CheckCount}}<span class="tag warn">{{.CheckCount}}</span>{{else}}0{{end}}</td>
<td>{{.TorqueCount}}</td>
<td class="left">{{if .PdfFile}}{{.PdfFile}}{{else}}<span class="off">-</span>{{end}}</td>
<td>{{if .RefTime}}{{.RefTime}}{{else}}<span class="off">暂无实绩</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
<h2>二、各工位工艺流程与步骤明细</h2>
{{range .Stations}}
{{if .Steps}}
<table>
<thead>
<tr><th colspan="6" class="st-name">工位{{.StationNo}} {{.StationName}} · {{if .FlowName}}{{.FlowName}}{{else}}未绑定流程{{end}}</th></tr>
<tr><th style="width:64px;">步骤号</th><th style="width:26%;">步骤名称</th><th>采集方式</th><th>检测确认</th><th>拧紧采集</th><th>考核标准</th></tr>
</thead>
<tbody>
{{range .Steps}}
<tr>
<td>{{.Seq}}</td><td class="left">{{.Name}}</td><td>{{.CollectType}}</td>
<td>{{if .NeedCheck}}<span class="tag warn">需检验</span>{{else}}{{end}}</td>
<td>{{if .IsTorque}}<span class="ok">是</span>{{else}}{{end}}</td>
<td class="left">{{.Criteria}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{end}}
<h2>三、物料清单</h2>
{{if .BomGroups}}
{{range .BomGroups}}
<table>
<thead>
<tr><th colspan="9" class="st-name">BOM{{.BomName}}</th></tr>
<tr><th>图号</th><th>物料名称</th><th>规格</th><th>单位</th><th>管理方式</th><th>装配工位</th><th>单台用量</th><th>损耗率(%)</th><th>需求总量</th></tr>
2026-09-15 16:37:39 +08:00
</thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.MaterialCode}}</td><td class="left">{{.MaterialName}}</td><td class="left">{{.Spec}}</td>
<td>{{.Unit}}</td><td>{{.ManageMode}}</td><td>{{.StationNo}}</td>
<td>{{.UnitQty}}</td><td>{{.LossRate}}</td><td>{{.RequiredQty}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{else}}
<p class="hint">该产品型号暂无物料清单。</p>
{{end}}
<h2>四、检验卡项(所有「检测确认」步骤)</h2>
{{if .CheckItems}}
<table>
<thead><tr><th style="width:80px;">工位号</th><th>工位名称</th><th>检验步骤</th><th>判定标准</th></tr></thead>
<tbody>
{{range .CheckItems}}
<tr><td>{{.StationNo}}</td><td class="left">{{.StationName}}</td><td class="left">{{.StepName}}</td><td class="left">{{.Criteria}}</td></tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="hint">该产品型号暂无检验卡项(工艺流程未设置「需要检测确认」的步骤)。</p>
{{end}}
<div class="sign">
<div>编制:</div>
<div>审核:</div>
<div>批准:</div>
</div>
<div class="bar no-print">
<button onclick="window.print()">打印 / 另存为 PDF</button>
</div>
</div>
</body>
</html>`
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
}