Files
SunYF 37f10d3a13 refactor: 重构产线工位与备料流程,移除冗余字段
1. 移除工单、工件、日排产中的冗余工位组合/接驳台字段
2. 新增工位接驳台编码字段与唯一约束,关联WMS接驳台主数据
3. 重构日排产为工位产量分配模式,替代原接驳台列表
4. 新增备料单工位级字段与分批出库支持
5. 移除定时同步可生产数量,改为WMS实时拉取接口
6. 过滤操作日志,排除登录/退出相关日志
7. 调整仪表盘工序展示逻辑为今日派工路线
8. 新增接驳台维护菜单与基础数据
2026-09-19 07:50:21 +08:00

329 lines
10 KiB
Go
Raw Permalink 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"
"strings"
"bj_power_mes/common/httpx"
"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"
"bj_power_mes/internal/logic"
"bj_power_mes/internal/svc"
)
// ProcessCardHandler GET /api/v1/process-card?sn=xxx
// 按 SN 生成可打印流程卡。数据经工单联查(产品名称/数量),工序时间线补全操作人与完成时间,
// 拧紧数据(如有拧紧步骤)渲染到卡尾。
// 带 ?check=1 时返回 JSON {missing:[缺失项…]} 供前端"打印前预检"弹窗,不渲染 HTML。
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
}
card, missing, err := buildCard(r.Context(), svcCtx, sn)
if err != nil {
httpx.Fail(w, 2401, err.Error())
return
}
if r.URL.Query().Get("check") == "1" {
httpx.Ok(w, map[string]any{"sn": sn, "missing": missing})
return
}
html, err := renderProcessCard(card)
if err != nil {
httpx.Fail(w, 2402, "流程卡生成失败:"+err.Error())
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
}
}
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
}
type cardTmpl struct {
Sn string
OrderNo string
Product string
Quantity int
CreatedAt string
Processes []cardProcess
Materials []string
Operator string
Inspector string
DoneTime string
Result string
NeedTorque bool
TorqueRows []cardTorque
}
// 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{}
if wp.OrderNo == "" {
missing = append(missing, "工单号")
} else {
t.OrderNo = wp.OrderNo
}
t.CreatedAt = wp.CreatedAt.Format("2006-01-02 15:04")
// 产品信息:工件 → 工单 联查(修复 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
}
t.Quantity = wo.Quantity
}
if t.Product == "" {
missing = append(missing, "产品名称")
}
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)))
}
cp := cardProcess{
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"
}
t.Processes = append(t.Processes, cp)
}
if len(procs) == 0 {
missing = append(missing, "工序报工记录")
}
// 步骤考核数据按工序实绩 ID 归组,回填到对应工序行的"采集参数"
steps, _ := client.StepData.Query().Where(stepdata.Sn(sn)).All(ctx)
stepByProc := map[int][]cardStep{}
for _, s := range steps {
stepByProc[s.ProcessId] = append(stepByProc[s.ProcessId], cardStep{Name: s.StepName, Value: s.ValueText, OK: s.IsOK})
}
for i := range t.Processes {
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"),
})
}
if needTorque && len(t.TorqueRows) == 0 {
missing = append(missing, "拧紧数据")
}
// 关联物料(批次/电气件SN
assoc, _ := client.AssociationTrace.Query().
Where(associationtrace.FinishedSn(sn)).First(ctx)
if assoc != nil {
t.Operator = assoc.Operator
for _, b := range assoc.BatchItems {
t.Materials = append(t.Materials, b)
}
for _, s := range assoc.SerialItems {
t.Materials = append(t.Materials, s)
}
}
return t, missing, nil
}
// hasTorqueStep 判断该工件加工路径上是否存在"拧紧采集"步骤(经 工位→流程→步骤 链)
// 路径由今日工位派工(station_process)派生
func hasTorqueStep(ctx context.Context, client *ent.Client, sn string, wp *ent.Workpiece) bool {
route, err := logic.RouteStations(ctx, client, logic.TodayStr(), "")
if err != nil || len(route) == 0 {
return false
}
stations, err := client.Station.Query().
Where(station.StationNoIn(route...), 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
}
func itoaCard(n int) string { return fmt.Sprintf("%d", n) }
func renderProcessCard(t *cardTmpl) (string, error) {
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; }
h2 { font-size:15px; margin:14px 0 6px; border-left:4px solid #1f2937; padding-left:8px; }
@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>
<span>数量:<b>{{.Quantity}}</b></span>
<span>登记时间:<b>{{.CreatedAt}}</b></span>
<span>判定:<b>{{if eq .Result "NG"}}<span class="ng">NG</span>{{else}}{{.Result}}{{end}}</b></span>
</div>
{{if .Processes}}
<h2>工序流转记录</h2>
<table>
<thead><tr><th>工序</th><th>工序名称</th><th>工位</th><th>操作人</th><th>完成时间</th><th>采集参数</th><th>结果</th></tr></thead>
<tbody>
{{range .Processes}}
<tr>
<td>工序{{.Code}}</td><td>{{.Name}}</td><td>{{.Station}}</td><td>{{.Operator}}</td><td>{{.DoneTime}}</td>
<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>
{{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}}
{{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
}