feat: 重构BOM架构,新增基础设置与虚拟工位管理功能

本次重构删除原有BOM物料清单表,改用工单工艺组合×工艺物料清单作为唯一用料来源;新增系统基础设置表支持WMS地址、日志保留天数等配置,新增虚拟工位作业管理后台接口与前端页签控制功能,同时优化工位号校验逻辑、事件日志自动清理与工单用料查询能力。
This commit is contained in:
SunYF
2026-09-23 11:41:28 +08:00
parent d3eda6b588
commit f274da044c
74 changed files with 3637 additions and 5586 deletions
@@ -106,3 +106,9 @@ func BindPanelInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
httpx.Ok(w, data)
}
}
// BindPanelAdminHandler GET /api/v1/workpiece/bind-panel?sn=&flowId=
// MES 管理后台「虚拟工位作业」页拉取绑定面板(与终端同逻辑,JWT 保护)
func BindPanelAdminHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return BindPanelInternalHandler(svcCtx)
}
@@ -1,341 +0,0 @@
package production
import (
"net/http"
"strconv"
"bj_power_mes/common/httpx"
"bj_power_mes/internal/logic"
"bj_power_mes/internal/svc"
"github.com/xuri/excelize/v2"
)
// ---------- BOM ----------
// ImportBomHandler POST /bom/import 产品物料清单(BOM) Excel 导入。
// 模板列:产品编号|物料清单名称|物料编码|名称|规格|单位|单台用量|损耗率|相关标准|备注(原「装配工位」列忽略)
func ImportBomHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(svcCtx.Config.Upload.MaxMB << 20); err != nil {
httpx.BadRequest(w, "上传文件过大或格式错误")
return
}
f, _, err := r.FormFile("file")
if err != nil {
httpx.BadRequest(w, "请选择 Excel 文件")
return
}
defer f.Close()
xlsx, err := excelize.OpenReader(f)
if err != nil {
httpx.Fail(w, 3109, "Excel 解析失败:"+err.Error())
return
}
sheet := xlsx.GetSheetName(0)
rows, err := xlsx.GetRows(sheet)
if err != nil {
httpx.Fail(w, 3110, "读取 Excel 失败:"+err.Error())
return
}
// 按 (产品编号, 物料清单名称) 分组,每组一次 SaveBom(覆盖式 upsert)
type key struct{ pc, bn string }
groups := map[key][]logic.BomItemReq{}
order := []key{}
count := 0
for i, row := range rows {
if i == 0 {
continue // 跳过表头
}
if len(row) < 3 {
continue
}
productCode := trimCell(row, 0)
bomName := trimCell(row, 1)
materialCode := trimCell(row, 2)
if productCode == "" || materialCode == "" {
continue
}
if bomName == "" {
bomName = "默认"
}
item := logic.BomItemReq{
ProductCode: productCode,
BomName: bomName,
MaterialCode: materialCode,
MaterialName: trimCell(row, 3),
Spec: trimCell(row, 4),
Unit: trimCell(row, 5),
ManageMode: "2",
UnitQty: parseFloat(trimCell(row, 6)),
LossRate: parseFloat(trimCell(row, 7)),
RelatedStandard: trimCell(row, 9),
}
k := key{productCode, bomName}
if _, ok := groups[k]; !ok {
order = append(order, k)
}
groups[k] = append(groups[k], item)
count++
}
if count == 0 {
httpx.Fail(w, 3111, "未解析到有效物料清单行(至少需产品编号与图号)")
return
}
for _, k := range order {
if err := logic.New(svcCtx).SaveBom(r.Context(), k.pc, k.bn, groups[k], operator(r, "")); err != nil {
httpx.Fail(w, 3112, "导入失败:"+err.Error())
return
}
}
httpx.OkMessage(w, "导入成功", map[string]any{"count": count})
}
}
func trimCell(row []string, i int) string {
if i >= len(row) {
return ""
}
return strconvTrimSpace(row[i])
}
func strconvTrimSpace(s string) string {
out := []rune{}
for _, c := range s {
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
continue
}
out = append(out, c)
}
return string(out)
}
func parseFloat(s string) float64 {
if s == "" {
return 0
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return v
}
func SaveBomHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
ProductCode string `json:"productCode"`
BomName string `json:"bomName"` // 同型号多份 BOM 并存时的名称(空=「默认」)
Items []logic.BomItemReq `json:"items"`
}
if err := httpx.ParseJSON(r, &req); err != nil {
httpx.BadRequest(w, "请求体解析失败")
return
}
if req.ProductCode == "" {
httpx.BadRequest(w, "请选择产品编码")
return
}
if err := logic.New(svcCtx).SaveBom(r.Context(), req.ProductCode, req.BomName, req.Items, operator(r, "")); err != nil {
httpx.Fail(w, 3101, err.Error())
return
}
httpx.OkMessage(w, "保存成功", nil)
}
}
func ListBomHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := logic.New(svcCtx).ListBom(r.Context(), r.URL.Query().Get("productCode"), r.URL.Query().Get("bomName"))
if err != nil {
httpx.Fail(w, 3102, err.Error())
return
}
httpx.Ok(w, data)
}
}
// ListBomNamesHandler GET /bom/names?productCode= — 型号下并存的 BOM 名称列表(工单建单选 BOM 用)
func ListBomNamesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := logic.New(svcCtx).ListBomNames(r.Context(), r.URL.Query().Get("productCode"))
if err != nil {
httpx.Fail(w, 3107, err.Error())
return
}
httpx.Ok(w, data)
}
}
// DeleteBomItemHandler POST /bom/item/delete {id} — 移除 BOM 中一条物料(按行主键)
func DeleteBomItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Id int `json:"id"`
}
if err := httpx.ParseJSON(r, &req); err != nil || req.Id <= 0 {
httpx.BadRequest(w, "缺少 id")
return
}
if err := logic.New(svcCtx).DeleteBomItem(r.Context(), req.Id, operator(r, "")); err != nil {
httpx.Fail(w, 3108, err.Error())
return
}
httpx.OkMessage(w, "已移除", nil)
}
}
// ---------- 备料单 ----------
// GenerateMaterialHandler 按日排产生成备料单(生成前校验 BOM 物料在 WMS 档案存在)
func GenerateMaterialHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
PlanDate string `json:"planDate"`
}
if err := httpx.ParseJSON(r, &req); err != nil {
httpx.BadRequest(w, "请求体解析失败")
return
}
count, missing, err := logic.New(svcCtx).GenerateMaterialRequest(r.Context(), req.PlanDate, operator(r, ""))
if err != nil {
httpx.Fail(w, 3103, err.Error())
return
}
if len(missing) > 0 {
// 有物料在 WMS 档案不存在:不生成,把缺失列表返回给前端弹窗
httpx.Ok(w, map[string]any{"count": 0, "missing": missing, "blocked": true})
return
}
httpx.OkMessage(w, "生成完成", map[string]any{"count": count, "blocked": false})
}
}
// AutoOutboundHandler POST /material-requests/auto-outbound 备料自动出库(一键补出)。
// 日常由「生成备料单」自动触发(客户第1条:平时无需操作);库存补齐后库管可在此一键补出。
func AutoOutboundHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
PlanDate string `json:"planDate"`
OrderNo string `json:"orderNo"`
}
if err := httpx.ParseJSON(r, &req); err != nil {
httpx.BadRequest(w, "请求体解析失败")
return
}
if req.PlanDate == "" && req.OrderNo == "" {
httpx.BadRequest(w, "planDate 与 orderNo 至少填一个")
return
}
done, short := logic.New(svcCtx).AutoOutboundRequests(r.Context(), req.PlanDate, req.OrderNo, operator(r, ""))
httpx.OkMessage(w, "自动出库完成", map[string]any{"done": done, "short": short})
}
}
func ListMaterialRequestsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
data, err := logic.New(svcCtx).ListMaterialRequests(r.Context(), logic.MaterialRequestQuery{
OrderNo: q.Get("orderNo"),
PlanDate: q.Get("planDate"),
Status: q.Get("status"),
MaterialCode: q.Get("materialCode"),
MaterialName: q.Get("materialName"),
TargetDock: q.Get("targetDock"),
StationNo: q.Get("stationNo"),
Source: q.Get("source"),
RequestNo: q.Get("requestNo"),
})
if err != nil {
httpx.Fail(w, 3104, err.Error())
return
}
httpx.Ok(w, data)
}
}
// ListMaterialRequestsInternalHandler 内部接口:供 WMS 拉取备料单(含 target_dock),X-API-TOKEN 保护。
// WMS AGV 配送页据此展示待发料行,仓管确认 target_dock 后调用 WMS AGV 下发。
func ListMaterialRequestsInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
data, err := logic.New(svcCtx).ListMaterialRequests(r.Context(), logic.MaterialRequestQuery{
OrderNo: q.Get("orderNo"),
PlanDate: q.Get("planDate"),
Status: q.Get("status"),
StationNo: q.Get("stationNo"),
})
if err != nil {
httpx.Fail(w, 3105, err.Error())
return
}
httpx.Ok(w, data)
}
}
// MarkMaterialRequestStatusInternalHandler 内部接口:WMS 下发 AGV 后回写备料单状态(DELIVERING/DONE)。
// 供 WMS AGV 任务推进状态时联动 MES 备料单,保持一致。
func MarkMaterialRequestStatusInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
RequestNo string `json:"requestNo"`
Status string `json:"status"`
}
if err := httpx.ParseJSON(r, &req); err != nil || req.RequestNo == "" {
httpx.BadRequest(w, "请求体缺失")
return
}
if req.Status != "DELIVERING" && req.Status != "DONE" {
httpx.Fail(w, 3106, "非法状态")
return
}
if err := logic.New(svcCtx).SetMaterialRequestStatus(r.Context(), req.RequestNo, req.Status); err != nil {
httpx.Fail(w, 3106, err.Error())
return
}
httpx.Ok(w, nil)
}
}
// ---------- 接驳台 / 接料确认 ----------
// 接驳台查询/维护接口见 dock.go(真源已迁 MES 本地 dock 表)。
// ReceiveMaterialRequestHandler POST /material-requests/receive 接料确认(MES 备料页人工点「已接料」)。
// qty 可空(≤0 表示确认本批全部剩余);累加 receivedQty(已接料),上限为已发出量。
func ReceiveMaterialRequestHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
RequestNo string `json:"requestNo"`
Qty int `json:"qty"`
}
if err := httpx.ParseJSON(r, &req); err != nil || req.RequestNo == "" {
httpx.BadRequest(w, "缺少备料单号")
return
}
if err := logic.New(svcCtx).ReceiveMaterialRequest(r.Context(), req.RequestNo, req.Qty, operator(r, "")); err != nil {
httpx.Fail(w, 3114, err.Error())
return
}
httpx.OkMessage(w, "已接料", nil)
}
}
// StationReceiveMaterialInternalHandler POST /api/internal/station/receive-material 接料确认内部接口。
// 供工位终端代理调用(工人扫码接料主入口),X-API-TOKEN 保护。逻辑同 ReceiveMaterialRequestHandler。
func StationReceiveMaterialInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
RequestNo string `json:"requestNo"`
Qty int `json:"qty"`
}
if err := httpx.ParseJSON(r, &req); err != nil || req.RequestNo == "" {
httpx.BadRequest(w, "缺少备料单号")
return
}
if err := logic.New(svcCtx).ReceiveMaterialRequest(r.Context(), req.RequestNo, req.Qty, operator(r, "")); err != nil {
httpx.Fail(w, 3114, err.Error())
return
}
httpx.Ok(w, nil)
}
}
@@ -1,10 +1,11 @@
package production
package production
import (
"net/http"
"strconv"
"bj_power_mes/common/httpx"
"bj_power_mes/internal/logic"
"bj_power_mes/internal/svc"
)
@@ -31,3 +32,158 @@ func ListMaterialsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
httpx.Ok(w, list)
}
}
// ---------- 备料单 ----------
// GenerateMaterialHandler 按日排产生成备料单(生成前校验 BOM 物料在 WMS 档案存在)
func GenerateMaterialHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
PlanDate string `json:"planDate"`
}
if err := httpx.ParseJSON(r, &req); err != nil {
httpx.BadRequest(w, "请求体解析失败")
return
}
count, missing, err := logic.New(svcCtx).GenerateMaterialRequest(r.Context(), req.PlanDate, operator(r, ""))
if err != nil {
httpx.Fail(w, 3103, err.Error())
return
}
if len(missing) > 0 {
// 有物料在 WMS 档案不存在:不生成,把缺失列表返回给前端弹窗
httpx.Ok(w, map[string]any{"count": 0, "missing": missing, "blocked": true})
return
}
httpx.OkMessage(w, "生成完成", map[string]any{"count": count, "blocked": false})
}
}
// AutoOutboundHandler POST /material-requests/auto-outbound 备料自动出库(一键补出)。
// 日常由「生成备料单」自动触发(客户第1条:平时无需操作);库存补齐后库管可在此一键补出。
func AutoOutboundHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
PlanDate string `json:"planDate"`
OrderNo string `json:"orderNo"`
}
if err := httpx.ParseJSON(r, &req); err != nil {
httpx.BadRequest(w, "请求体解析失败")
return
}
if req.PlanDate == "" && req.OrderNo == "" {
httpx.BadRequest(w, "planDate 与 orderNo 至少填一个")
return
}
done, short := logic.New(svcCtx).AutoOutboundRequests(r.Context(), req.PlanDate, req.OrderNo, operator(r, ""))
httpx.OkMessage(w, "自动出库完成", map[string]any{"done": done, "short": short})
}
}
func ListMaterialRequestsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
data, err := logic.New(svcCtx).ListMaterialRequests(r.Context(), logic.MaterialRequestQuery{
OrderNo: q.Get("orderNo"),
PlanDate: q.Get("planDate"),
Status: q.Get("status"),
MaterialCode: q.Get("materialCode"),
MaterialName: q.Get("materialName"),
TargetDock: q.Get("targetDock"),
StationNo: q.Get("stationNo"),
Source: q.Get("source"),
RequestNo: q.Get("requestNo"),
})
if err != nil {
httpx.Fail(w, 3104, err.Error())
return
}
httpx.Ok(w, data)
}
}
// ListMaterialRequestsInternalHandler 内部接口:供 WMS 拉取备料单(含 target_dock),X-API-TOKEN 保护。
// WMS AGV 配送页据此展示待发料行,仓管确认 target_dock 后调用 WMS AGV 下发。
func ListMaterialRequestsInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
data, err := logic.New(svcCtx).ListMaterialRequests(r.Context(), logic.MaterialRequestQuery{
OrderNo: q.Get("orderNo"),
PlanDate: q.Get("planDate"),
Status: q.Get("status"),
StationNo: q.Get("stationNo"),
})
if err != nil {
httpx.Fail(w, 3105, err.Error())
return
}
httpx.Ok(w, data)
}
}
// MarkMaterialRequestStatusInternalHandler 内部接口:WMS 下发 AGV 后回写备料单状态(DELIVERING/DONE)。
// 供 WMS AGV 任务推进状态时联动 MES 备料单,保持一致。
func MarkMaterialRequestStatusInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
RequestNo string `json:"requestNo"`
Status string `json:"status"`
}
if err := httpx.ParseJSON(r, &req); err != nil || req.RequestNo == "" {
httpx.BadRequest(w, "请求体缺失")
return
}
if req.Status != "DELIVERING" && req.Status != "DONE" {
httpx.Fail(w, 3106, "非法状态")
return
}
if err := logic.New(svcCtx).SetMaterialRequestStatus(r.Context(), req.RequestNo, req.Status); err != nil {
httpx.Fail(w, 3106, err.Error())
return
}
httpx.Ok(w, nil)
}
}
// ---------- 接驳台 / 接料确认 ----------
// 接驳台查询/维护接口见 dock.go(真源已迁 MES 本地 dock 表)。
// ReceiveMaterialRequestHandler POST /material-requests/receive 接料确认(MES 备料页人工点「已接料」)。
// qty 可空(≤0 表示确认本批全部剩余);累加 receivedQty(已接料),上限为已发出量。
func ReceiveMaterialRequestHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
RequestNo string `json:"requestNo"`
Qty int `json:"qty"`
}
if err := httpx.ParseJSON(r, &req); err != nil || req.RequestNo == "" {
httpx.BadRequest(w, "缺少备料单号")
return
}
if err := logic.New(svcCtx).ReceiveMaterialRequest(r.Context(), req.RequestNo, req.Qty, operator(r, "")); err != nil {
httpx.Fail(w, 3114, err.Error())
return
}
httpx.OkMessage(w, "已接料", nil)
}
}
// StationReceiveMaterialInternalHandler POST /api/internal/station/receive-material 接料确认内部接口。
// 供工位终端代理调用(工人扫码接料主入口),X-API-TOKEN 保护。逻辑同 ReceiveMaterialRequestHandler。
func StationReceiveMaterialInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
RequestNo string `json:"requestNo"`
Qty int `json:"qty"`
}
if err := httpx.ParseJSON(r, &req); err != nil || req.RequestNo == "" {
httpx.BadRequest(w, "缺少备料单号")
return
}
if err := logic.New(svcCtx).ReceiveMaterialRequest(r.Context(), req.RequestNo, req.Qty, operator(r, "")); err != nil {
httpx.Fail(w, 3114, err.Error())
return
}
httpx.Ok(w, nil)
}
}
@@ -154,11 +154,11 @@ func DoneWorkpieceInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
}
}
// BomInternalHandler BOM 物料清单(工位终端上线位「领料确认」按物料清单展示应领料)。
// GET ?productCode=&bomName=,返回 []*ent.BomItem。
func BomInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
// OrderUsageInternalHandler 工单单台用料(工位终端上线位「领料确认」按工单工艺组合的物料清单展示应领料)。
// GET ?orderNo=,返回 []logic.UsageRow。BOM 已删除:用料唯一来源 = 工单工艺组合×工艺物料清单。
func OrderUsageInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := logic.New(svcCtx).ListBom(r.Context(), r.URL.Query().Get("productCode"), r.URL.Query().Get("bomName"))
data, err := logic.New(svcCtx).GetOrderUsage(r.Context(), r.URL.Query().Get("orderNo"))
if err != nil {
httpx.Fail(w, 2009, err.Error())
return
@@ -167,6 +167,11 @@ func BomInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
}
}
// OrderUsageHandler GET /order-usage?orderNo= 工单单台用料(管理后台工单详情只读汇总展示)
func OrderUsageHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return OrderUsageInternalHandler(svcCtx)
}
// ---------- 看板内部接口 ----------
// DashboardSnapshotInternalHandler 看板全量快照。