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

590 lines
18 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"
"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/logic"
"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) 产品最近工单(仅取工单号/计划时间用于卡头展示;路线不再存工单)
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")
}
}
}
// 3) 工位组合:由「今日工位派工(station_process)」派生;未派工则展示全产线已配置工艺流的工位
seq, _ := logic.RouteStations(ctx, client, logic.TodayStr(), "")
if len(seq) == 0 {
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 = "今日未派工,按全产线已配置工位顺序展示"
} else {
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
}