Files
bj_power/bj_power_mes/internal/handler/processcard_model.go
T
SunYF 4a5e2d7bac feat: 完成多模块迭代更新
- 新增过程巡检按步骤上报、数量不符上报、工位退料功能
- 增加工单工程编号三级归属字段
- 新增物料安全库存与缺货预警
- 新增附件管理、退库单管理模块
- 优化生产看板展示逻辑与前端页面文案
- 清理冗余的工艺路线相关代码与备份文件
2026-09-15 16:37:39 +08:00

587 lines
18 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</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
}