2026-08-31 08:06:55 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
2026-09-04 14:18:53 +08:00
|
|
|
|
"context"
|
2026-08-31 08:06:55 +08:00
|
|
|
|
"fmt"
|
|
|
|
|
|
"html/template"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
|
|
"bj_power_mes/common/httpx"
|
2026-09-04 14:18:53 +08:00
|
|
|
|
"bj_power_mes/ent"
|
|
|
|
|
|
"bj_power_mes/ent/associationtrace"
|
|
|
|
|
|
"bj_power_mes/ent/processstep"
|
|
|
|
|
|
"bj_power_mes/ent/station"
|
|
|
|
|
|
"bj_power_mes/ent/stepdata"
|
|
|
|
|
|
"bj_power_mes/ent/torquerecord"
|
|
|
|
|
|
"bj_power_mes/ent/workorder"
|
|
|
|
|
|
"bj_power_mes/ent/workpiece"
|
|
|
|
|
|
"bj_power_mes/ent/workpieceprocess"
|
2026-08-31 08:06:55 +08:00
|
|
|
|
"bj_power_mes/internal/svc"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ProcessCardHandler GET /api/v1/process-card?sn=xxx
|
2026-09-04 14:18:53 +08:00
|
|
|
|
// 按 SN 生成可打印流程卡。数据经工单联查(产品名称/数量),工序时间线补全操作人与完成时间,
|
|
|
|
|
|
// 拧紧数据(如有拧紧步骤)渲染到卡尾。
|
|
|
|
|
|
// 带 ?check=1 时返回 JSON {missing:[缺失项…]} 供前端"打印前预检"弹窗,不渲染 HTML。
|
2026-08-31 08:06:55 +08:00
|
|
|
|
func ProcessCardHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
sn := strings.TrimSpace(r.URL.Query().Get("sn"))
|
|
|
|
|
|
if sn == "" {
|
|
|
|
|
|
httpx.BadRequest(w, "缺少 sn 参数")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
|
card, missing, err := buildCard(r.Context(), svcCtx, sn)
|
2026-08-31 08:06:55 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
httpx.Fail(w, 2401, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
|
if r.URL.Query().Get("check") == "1" {
|
|
|
|
|
|
httpx.Ok(w, map[string]any{"sn": sn, "missing": missing})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
html, err := renderProcessCard(card)
|
2026-08-31 08:06:55 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
httpx.Fail(w, 2402, "流程卡生成失败:"+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
|
_, _ = w.Write([]byte(html))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
|
type cardProcess struct {
|
|
|
|
|
|
ID int
|
|
|
|
|
|
Code string
|
|
|
|
|
|
Name string
|
|
|
|
|
|
Station string
|
|
|
|
|
|
Operator string
|
|
|
|
|
|
Status string
|
|
|
|
|
|
Result string
|
|
|
|
|
|
DoneTime string
|
|
|
|
|
|
Steps []cardStep
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type cardStep struct {
|
|
|
|
|
|
Name string
|
|
|
|
|
|
Value string
|
|
|
|
|
|
OK bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type cardTorque struct {
|
|
|
|
|
|
ScrewNo string
|
|
|
|
|
|
Torque string
|
|
|
|
|
|
Angle string
|
|
|
|
|
|
Result string
|
|
|
|
|
|
Operator string
|
|
|
|
|
|
Time string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-31 08:06:55 +08:00
|
|
|
|
type cardTmpl struct {
|
|
|
|
|
|
Sn string
|
|
|
|
|
|
OrderNo string
|
|
|
|
|
|
Product string
|
2026-09-04 14:18:53 +08:00
|
|
|
|
Quantity int
|
2026-08-31 08:06:55 +08:00
|
|
|
|
CreatedAt string
|
|
|
|
|
|
Processes []cardProcess
|
|
|
|
|
|
Materials []string
|
|
|
|
|
|
Operator string
|
|
|
|
|
|
Inspector string
|
|
|
|
|
|
DoneTime string
|
|
|
|
|
|
Result string
|
2026-09-04 14:18:53 +08:00
|
|
|
|
NeedTorque bool
|
|
|
|
|
|
TorqueRows []cardTorque
|
2026-08-31 08:06:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
|
// buildCard 汇总流程卡所需全部数据(ent 实体直查),并计算缺失项。
|
|
|
|
|
|
// missing 非空时表示存在数据不完整,前端应提示"是否继续打印"。
|
|
|
|
|
|
func buildCard(ctx context.Context, svcCtx *svc.ServiceContext, sn string) (*cardTmpl, []string, error) {
|
|
|
|
|
|
client := svcCtx.EntClient
|
|
|
|
|
|
wp, err := client.Workpiece.Query().Where(workpiece.Sn(sn)).First(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, fmt.Errorf("该SN无追溯数据")
|
|
|
|
|
|
}
|
|
|
|
|
|
t := &cardTmpl{Sn: sn, Result: "待完工", NeedTorque: false}
|
|
|
|
|
|
missing := []string{}
|
2026-08-31 08:06:55 +08:00
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
|
if wp.OrderNo == "" {
|
|
|
|
|
|
missing = append(missing, "工单号")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
t.OrderNo = wp.OrderNo
|
|
|
|
|
|
}
|
|
|
|
|
|
t.CreatedAt = wp.CreatedAt.Format("2006-01-02 15:04")
|
2026-08-31 08:06:55 +08:00
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
|
// 产品信息:工件 → 工单 联查(修复 wp.productType 恒空 bug)
|
|
|
|
|
|
var wo *ent.WorkOrder
|
|
|
|
|
|
if t.OrderNo != "" {
|
|
|
|
|
|
wo, _ = client.WorkOrder.Query().Where(workorder.WorkOrderNo(t.OrderNo)).First(ctx)
|
|
|
|
|
|
}
|
|
|
|
|
|
if wo != nil {
|
|
|
|
|
|
t.Product = wo.ProductName
|
|
|
|
|
|
if wo.ProductName == "" {
|
|
|
|
|
|
t.Product = wo.ProductCode
|
2026-08-31 08:06:55 +08:00
|
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
|
t.Quantity = wo.Quantity
|
|
|
|
|
|
}
|
|
|
|
|
|
if t.Product == "" {
|
|
|
|
|
|
missing = append(missing, "产品名称")
|
2026-08-31 08:06:55 +08:00
|
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
|
if wo == nil || t.Quantity <= 0 {
|
|
|
|
|
|
missing = append(missing, "产品数量")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 工序时间线(操作人 + 完成时间)
|
|
|
|
|
|
procs, _ := client.WorkpieceProcess.Query().
|
|
|
|
|
|
Where(workpieceprocess.Sn(sn)).Order(workpieceprocess.ByProcessCode()).All(ctx)
|
|
|
|
|
|
for _, p := range procs {
|
|
|
|
|
|
doneAt := ""
|
|
|
|
|
|
if p.EndedAt != nil {
|
|
|
|
|
|
doneAt = p.EndedAt.Format("2006-01-02 15:04")
|
|
|
|
|
|
}
|
|
|
|
|
|
if p.Operator == "" {
|
|
|
|
|
|
missing = append(missing, fmt.Sprintf("工序%s报工记录(缺操作人)", itoaCard(p.ProcessCode)))
|
|
|
|
|
|
}
|
|
|
|
|
|
if p.EndedAt == nil {
|
|
|
|
|
|
missing = append(missing, fmt.Sprintf("工序%s报工记录(缺完成时间)", itoaCard(p.ProcessCode)))
|
|
|
|
|
|
}
|
2026-08-31 08:06:55 +08:00
|
|
|
|
cp := cardProcess{
|
2026-09-04 14:18:53 +08:00
|
|
|
|
ID: p.ID,
|
|
|
|
|
|
Code: fmt.Sprintf("%d", p.ProcessCode),
|
|
|
|
|
|
Name: p.ProcessName,
|
|
|
|
|
|
Station: p.StationNo,
|
|
|
|
|
|
Operator: p.Operator,
|
|
|
|
|
|
Status: p.Status,
|
|
|
|
|
|
Result: p.Result,
|
|
|
|
|
|
DoneTime: doneAt,
|
|
|
|
|
|
}
|
|
|
|
|
|
if p.Result == "NG" {
|
|
|
|
|
|
t.Result = "NG"
|
2026-08-31 08:06:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
t.Processes = append(t.Processes, cp)
|
|
|
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
|
if len(procs) == 0 {
|
|
|
|
|
|
missing = append(missing, "工序报工记录")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 步骤考核数据按工序实绩 ID 归组,回填到对应工序行的"采集参数"
|
|
|
|
|
|
steps, _ := client.StepData.Query().Where(stepdata.Sn(sn)).All(ctx)
|
|
|
|
|
|
stepByProc := map[int][]cardStep{}
|
2026-08-31 08:06:55 +08:00
|
|
|
|
for _, s := range steps {
|
2026-09-04 14:18:53 +08:00
|
|
|
|
stepByProc[s.ProcessId] = append(stepByProc[s.ProcessId], cardStep{Name: s.StepName, Value: s.ValueText, OK: s.IsOK})
|
2026-08-31 08:06:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
for i := range t.Processes {
|
2026-09-04 14:18:53 +08:00
|
|
|
|
t.Processes[i].Steps = stepByProc[t.Processes[i].ID]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 拧紧数据:工件所属流程含 isTorque 步骤时判定"需要拧紧数据"
|
|
|
|
|
|
needTorque := hasTorqueStep(ctx, client, sn, wp)
|
|
|
|
|
|
t.NeedTorque = needTorque
|
|
|
|
|
|
torque, _ := client.TorqueRecord.Query().
|
|
|
|
|
|
Where(torquerecord.Sn(sn)).Order(ent.Desc(torquerecord.FieldTime)).Limit(200).All(ctx)
|
|
|
|
|
|
for _, q := range torque {
|
|
|
|
|
|
t.TorqueRows = append(t.TorqueRows, cardTorque{
|
|
|
|
|
|
ScrewNo: q.ScrewNo, Torque: fmt.Sprintf("%.2f", q.Torque),
|
|
|
|
|
|
Angle: fmt.Sprintf("%.2f", q.Angle), Result: q.Result,
|
|
|
|
|
|
Operator: q.Operator, Time: q.Time.Format("2006-01-02 15:04:05"),
|
|
|
|
|
|
})
|
2026-08-31 08:06:55 +08:00
|
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
|
if needTorque && len(t.TorqueRows) == 0 {
|
|
|
|
|
|
missing = append(missing, "拧紧数据")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-10 16:59:15 +08:00
|
|
|
|
// 关联物料(批次/电气件SN)
|
2026-09-04 14:18:53 +08:00
|
|
|
|
assoc, _ := client.AssociationTrace.Query().
|
|
|
|
|
|
Where(associationtrace.FinishedSn(sn)).First(ctx)
|
2026-08-31 08:06:55 +08:00
|
|
|
|
if assoc != nil {
|
2026-09-04 14:18:53 +08:00
|
|
|
|
t.Operator = assoc.Operator
|
|
|
|
|
|
for _, b := range assoc.BatchItems {
|
|
|
|
|
|
t.Materials = append(t.Materials, b)
|
2026-08-31 08:06:55 +08:00
|
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
|
for _, s := range assoc.SerialItems {
|
|
|
|
|
|
t.Materials = append(t.Materials, s)
|
2026-08-31 08:06:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
|
return t, missing, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// hasTorqueStep 判断该工件加工路径上是否存在"拧紧采集"步骤(经 工位→流程→步骤 链)
|
|
|
|
|
|
func hasTorqueStep(ctx context.Context, client *ent.Client, sn string, wp *ent.Workpiece) bool {
|
|
|
|
|
|
procCodes := parseCardSeq(wp.ProcessSeq)
|
|
|
|
|
|
if len(procCodes) == 0 {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
stations, err := client.Station.Query().
|
|
|
|
|
|
Where(station.StationNoIn(procCodes...), station.FlowIdGT(0)).All(ctx)
|
|
|
|
|
|
if err != nil || len(stations) == 0 {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
flowIds := make([]int, 0, len(stations))
|
|
|
|
|
|
for _, st := range stations {
|
|
|
|
|
|
flowIds = append(flowIds, st.FlowId)
|
|
|
|
|
|
}
|
|
|
|
|
|
n, err := client.ProcessStep.Query().
|
|
|
|
|
|
Where(processstep.FlowIdIn(flowIds...), processstep.IsTorque(true)).Count(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return n > 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parseCardSeq 解析工单/工件工序组合 "1,3,5" → 工位号列表
|
|
|
|
|
|
func parseCardSeq(s string) []int {
|
|
|
|
|
|
out := []int{}
|
|
|
|
|
|
for _, p := range strings.Split(s, ",") {
|
|
|
|
|
|
p = strings.TrimSpace(p)
|
|
|
|
|
|
if p == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
var n int
|
|
|
|
|
|
if _, err := fmt.Sscanf(p, "%d", &n); err == nil && n >= 1 && n <= 12 {
|
|
|
|
|
|
out = append(out, n)
|
2026-08-31 08:06:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-04 14:18:53 +08:00
|
|
|
|
return out
|
|
|
|
|
|
}
|
2026-08-31 08:06:55 +08:00
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
|
func itoaCard(n int) string { return fmt.Sprintf("%d", n) }
|
|
|
|
|
|
|
|
|
|
|
|
func renderProcessCard(t *cardTmpl) (string, error) {
|
2026-08-31 08:06:55 +08:00
|
|
|
|
const tpl = `<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<title>流程卡 {{.Sn}}</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
|
|
|
|
body { font-family:"Microsoft YaHei",sans-serif; color:#1f2937; padding:24px; }
|
|
|
|
|
|
.card { max-width:900px; margin:0 auto; border:2px solid #333; border-radius:8px; padding:20px 24px; }
|
|
|
|
|
|
h1 { font-size:22px; text-align:center; border-bottom:2px solid #333; padding-bottom:10px; margin-bottom:14px; letter-spacing:4px; }
|
|
|
|
|
|
.meta { display:flex; flex-wrap:wrap; gap:8px 32px; font-size:14px; margin-bottom:14px; }
|
|
|
|
|
|
.meta b { font-weight:700; }
|
|
|
|
|
|
table { width:100%; border-collapse:collapse; font-size:13px; }
|
|
|
|
|
|
th, td { border:1px solid #999; padding:6px 8px; text-align:center; }
|
|
|
|
|
|
th { background:#f3f4f6; }
|
|
|
|
|
|
.ok { color:#15803d; font-weight:700; }
|
|
|
|
|
|
.ng { color:#dc2626; font-weight:700; }
|
|
|
|
|
|
.sub td { text-align:left; color:#374151; background:#fafafa; }
|
|
|
|
|
|
.sign { display:flex; justify-content:space-between; margin-top:22px; font-size:14px; }
|
|
|
|
|
|
.sign div { width:45%; border-top:1px solid #333; padding-top:6px; text-align:center; }
|
2026-09-04 14:18:53 +08:00
|
|
|
|
h2 { font-size:15px; margin:14px 0 6px; border-left:4px solid #1f2937; padding-left:8px; }
|
2026-08-31 08:06:55 +08:00
|
|
|
|
@media print { body { padding:0; } .card { border-color:#000; box-shadow:none; } .no-print { display:none; } }
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h1>产品流程卡</h1>
|
|
|
|
|
|
<div class="meta">
|
|
|
|
|
|
<span>SN:<b>{{.Sn}}</b></span>
|
|
|
|
|
|
<span>工单号:<b>{{.OrderNo}}</b></span>
|
|
|
|
|
|
<span>产品型号:<b>{{.Product}}</b></span>
|
2026-09-04 14:18:53 +08:00
|
|
|
|
<span>数量:<b>{{.Quantity}}</b></span>
|
2026-08-31 08:06:55 +08:00
|
|
|
|
<span>登记时间:<b>{{.CreatedAt}}</b></span>
|
|
|
|
|
|
<span>判定:<b>{{if eq .Result "NG"}}<span class="ng">NG</span>{{else}}{{.Result}}{{end}}</b></span>
|
|
|
|
|
|
</div>
|
2026-09-04 14:18:53 +08:00
|
|
|
|
{{if .Processes}}
|
|
|
|
|
|
<h2>工序流转记录</h2>
|
2026-08-31 08:06:55 +08:00
|
|
|
|
<table>
|
2026-09-04 14:18:53 +08:00
|
|
|
|
<thead><tr><th>工序</th><th>工序名称</th><th>工位</th><th>操作人</th><th>完成时间</th><th>采集参数</th><th>结果</th></tr></thead>
|
2026-08-31 08:06:55 +08:00
|
|
|
|
<tbody>
|
|
|
|
|
|
{{range .Processes}}
|
|
|
|
|
|
<tr>
|
2026-09-04 14:18:53 +08:00
|
|
|
|
<td>工序{{.Code}}</td><td>{{.Name}}</td><td>{{.Station}}</td><td>{{.Operator}}</td><td>{{.DoneTime}}</td>
|
2026-08-31 08:06:55 +08:00
|
|
|
|
<td>{{if .Steps}}{{range .Steps}}{{.Name}}={{.Value}}({{if .OK}}<span class="ok">OK</span>{{else}}<span class="ng">NG</span>{{end}}) {{end}}{{else}}-{{end}}</td>
|
|
|
|
|
|
<td>{{if eq .Result "NG"}}<span class="ng">NG</span>{{else}}OK{{end}}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
2026-09-04 14:18:53 +08:00
|
|
|
|
{{else}}
|
|
|
|
|
|
<p style="color:#b45309;">暂无工序报工记录。</p>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
{{if .NeedTorque}}
|
|
|
|
|
|
<h2>拧紧数据{{if not .TorqueRows}}(无数据){{end}}</h2>
|
|
|
|
|
|
{{if .TorqueRows}}
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead><tr><th>螺丝编号</th><th>扭矩(N·m)</th><th>角度(°)</th><th>结果</th><th>操作人</th><th>时间</th></tr></thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{{range .TorqueRows}}
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td>{{.ScrewNo}}</td><td>{{.Torque}}</td><td>{{.Angle}}</td>
|
|
|
|
|
|
<td>{{if eq .Result "NG"}}<span class="ng">NG</span>{{else}}<span class="ok">OK</span>{{end}}</td>
|
|
|
|
|
|
<td>{{.Operator}}</td><td>{{.Time}}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
{{end}}
|
2026-08-31 08:06:55 +08:00
|
|
|
|
{{if .Materials}}
|
|
|
|
|
|
<p style="margin-top:10px;font-size:13px;">关联物料/批次:{{range .Materials}}{{.}} {{end}}</p>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
<div class="sign">
|
|
|
|
|
|
<div>操作人签字:</div>
|
|
|
|
|
|
<div>检验员签字:</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="text-align:center;margin-top:14px;">
|
|
|
|
|
|
<button class="no-print" onclick="window.print()" style="padding:8px 28px;font-size:15px;">打 印</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>`
|
|
|
|
|
|
|
|
|
|
|
|
tmpl, err := template.New("card").Parse(tpl)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
var buf bytes.Buffer
|
|
|
|
|
|
if err := tmpl.Execute(&buf, t); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
return buf.String(), nil
|
|
|
|
|
|
}
|