Files
bj_power/bj_power_mes/internal/handler/processcard_model.go
T
SunYF d609ff8a9e refactor: 重构工艺工序相关命名与数据模型
1.  全局替换"工序"为"工艺"统一术语,包括页面文案、枚举、注释
2.  重构工单与工件工艺数据模型:
    - 新增工艺组合表flow_material作为备料/齐套唯一源头
    - 新增工位状态表station_state支持自主停单/恢复接单
    - 移除station_process派工表,改用工单工艺组合作为路线唯一源头
    - 替换processCode为flowId作为工艺关联标识
    - 重构工件当前进度字段为currentStationNo
3.  删除冗余的静态备份资源文件
4.  调整物料选择组件默认启用仅显示激活物料
5.  优化工单创建/编辑逻辑,新增工艺组合校验与保存
2026-09-22 11:32:29 +08:00

608 lines
19 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/station"
"bj_power_mes/ent/stepcriterion"
"bj_power_mes/ent/workorder"
"bj_power_mes/ent/workpieceprocess"
"bj_power_mes/internal/logic"
"bj_power_mes/internal/svc"
"bj_power_mes/internal/wmsclient"
)
// 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
FlowNames string // 使用该物料的工艺清单(经 flow_material 映射)
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) 定位产品型号(实时代理 WMS,成品档案 item_type=3WMS 不可达时降级用 code 兜底)
var pt *wmsclient.ProductTypeRow
if productTypeId > 0 {
pt, _ = logic.New(svcCtx).ProductTypeByID(ctx, productTypeId)
}
if pt == nil && productCode != "" {
pt, _ = logic.New(svcCtx).ProductTypeByCode(ctx, productCode)
}
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) 产品最近工单(卡头展示 + 工艺组合路线唯一来源)
var refWO *ent.WorkOrder
if productCode != "" {
refWO, _ = client.WorkOrder.Query().
Where(workorder.ProductCode(productCode)).
Order(ent.Desc(workorder.FieldID)).First(ctx)
}
if refWO != nil {
card.OrderNo = refWO.WorkOrderNo
if refWO.PlanStart != nil {
card.PlanStart = refWO.PlanStart.Format("2006-01-02 15:04")
}
if refWO.PlanEnd != nil {
card.PlanEnd = refWO.PlanEnd.Format("2006-01-02 15:04")
}
}
// 3) 工位组合:唯一路线源头 = 最近工单的工艺组合;未配置则展示全产线已配置工艺的工位
var seq []int
if items, _ := logic.RouteStationsFromWO(ctx, client, refWO); len(items) > 0 {
seq = logic.RouteStationsOfWO(items)
card.SeqSource = "取自最近工单的工艺组合(工单 " + refWO.WorkOrderNo + ""
} else {
sts, _ := client.Station.Query().Where(station.FlowIdGT(0)).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)
// 4) 工位 + 工艺流程 + 步骤 + 考核标准
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)}
if st != nil {
ms.RefTime = refTimes[st.FlowId]
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)
}
// 5) 物料清单(按 BOM 名称分组;工艺清单经 flow_material + process_flow 映射)
if productCode != "" {
// 物料编码 → 使用该物料的工艺名列表(工艺物料清单是装配绑定的唯一依据)
flowNames := map[string][]string{}
if fms, e := client.FlowMaterial.Query().All(ctx); e == nil {
flowAll, _ := client.ProcessFlow.Query().All(ctx)
nameById := map[int]string{}
for _, f := range flowAll {
nameById[f.ID] = f.Name
}
for _, fm := range fms {
if n := nameById[fm.FlowId]; n != "" {
flowNames[fm.MaterialCode] = append(flowNames[fm.MaterialCode], n)
}
}
}
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
}
fnames := "-"
if ns := flowNames[it.MaterialCode]; len(ns) > 0 {
fnames = strings.Join(ns, "、")
}
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),
FlowNames: fnames,
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,键=工艺ID)
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.FlowId]
if s == nil {
s = &span{}
m[p.FlowId] = 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 flowId, s := range m {
if !s.seen {
continue
}
if s.last == 0 {
out[flowId] = tsText(s.first) + " 起(未完成)"
continue
}
out[flowId] = 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>{{.FlowNames}}</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
}