refactor: 重构产线工位与备料流程,移除冗余字段
1. 移除工单、工件、日排产中的冗余工位组合/接驳台字段 2. 新增工位接驳台编码字段与唯一约束,关联WMS接驳台主数据 3. 重构日排产为工位产量分配模式,替代原接驳台列表 4. 新增备料单工位级字段与分批出库支持 5. 移除定时同步可生产数量,改为WMS实时拉取接口 6. 过滤操作日志,排除登录/退出相关日志 7. 调整仪表盘工序展示逻辑为今日派工路线 8. 新增接驳台维护菜单与基础数据
This commit is contained in:
@@ -4,16 +4,25 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bj_power_mes/ent"
|
||||
"bj_power_mes/ent/materialrequest"
|
||||
"bj_power_mes/ent/station"
|
||||
"bj_power_mes/ent/workorder"
|
||||
)
|
||||
|
||||
// GenerateMaterialRequest 按日排产自动算料生成备料单
|
||||
// 数量 = 该日所有工单计划数量 × 产品BOM单台用量×(1+损耗率),材料来源=该工单产品的 BOM
|
||||
// GenerateMaterialRequest 按日排产 + 今日工位派工(station_process) 自动算料,生成工位级备料单。
|
||||
//
|
||||
// 算法:
|
||||
// - 路线来源:今日 station_process 派工(工位号→工序编号)。某工序(1~12)今日派工到哪些工位,
|
||||
// 该工序的 BOM 物料就拆到这些工位各生成一行备料单(物料随工序上料)。
|
||||
// - 数量:单工位量 = 该工位分配产量(日排产 stationPlanQty,未配置则日总量平均到各派工工位)
|
||||
// × BOM 单台用量 ×(1+损耗率);同工序多工位时日总量按分配/平均拆分,不重复放大。
|
||||
// - 目标接驳台:取该派工工位的 station.dockCode(无接驳台则留空待仓管指定)。
|
||||
// - processCode=0 的 BOM 物料(不参与工位绑定):按日总量生成一行,工位/接驳台留空。
|
||||
//
|
||||
// 生成前校验:BOM 内所有物料编码须在 WMS 物料档案存在,有缺失返回 missing(不生成);
|
||||
// WMS 不可达/异常时降级为"全部存在"并记 eventlog,避免 WMS 抖动阻塞产线。
|
||||
// 返回值:count=本次生成条数;missing=WMS 中不存在的物料编码(非空时 count=0)。
|
||||
@@ -25,6 +34,29 @@ func (s *Service) GenerateMaterialRequest(ctx context.Context, planDate string,
|
||||
if len(plans) == 0 {
|
||||
return 0, nil, errors.New("该日期没有排产计划")
|
||||
}
|
||||
// 今日工位派工:工位号 → 工序编号
|
||||
procMap, err := RouteStationProcessCodeMap(ctx, s.ctx.EntClient, TodayStr(), "")
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if len(procMap) == 0 {
|
||||
return 0, nil, errors.New("今日未配置工位派工(station_process),无法生成备料单")
|
||||
}
|
||||
// 工序编号 → 工位号列表(反查,供 BOM 物料按工序拆到工位)
|
||||
stationsByProcess := map[int][]int{}
|
||||
stNos := make([]int, 0, len(procMap))
|
||||
for stNo, pc := range procMap {
|
||||
stationsByProcess[pc] = append(stationsByProcess[pc], stNo)
|
||||
stNos = append(stNos, stNo)
|
||||
}
|
||||
// 工位号 → 接驳台编码
|
||||
dockByStation := map[int]string{}
|
||||
if sts, e := s.ctx.EntClient.Station.Query().Where(station.StationNoIn(stNos...)).All(ctx); e == nil {
|
||||
for _, st := range sts {
|
||||
dockByStation[st.StationNo] = st.DockCode
|
||||
}
|
||||
}
|
||||
|
||||
type planBom struct {
|
||||
plan *ent.DailyPlan
|
||||
wo *ent.WorkOrder
|
||||
@@ -36,7 +68,6 @@ func (s *Service) GenerateMaterialRequest(ctx context.Context, planDate string,
|
||||
if plan.Status == "CANCELLED" || plan.Status == "DONE" {
|
||||
continue // 已取消/已完成的排产不再算料
|
||||
}
|
||||
// 解析工单 → 产品编码 → 该产品型号的物料清单(固定使用默认清单)
|
||||
wo, err := s.ctx.EntClient.WorkOrder.Query().
|
||||
Where(workorder.WorkOrderNo(plan.OrderNo)).Only(ctx)
|
||||
if err != nil {
|
||||
@@ -72,48 +103,194 @@ func (s *Service) GenerateMaterialRequest(ctx context.Context, planDate string,
|
||||
} else if len(missing) > 0 {
|
||||
return 0, missing, nil
|
||||
}
|
||||
// 阶段2:全部存在 → 算料生成
|
||||
// 阶段2:全部存在 → 按工位拆料生成
|
||||
created := 0
|
||||
// 备料单号基准:一次生成调用只取一次时间戳,序号全局递增,避免同一秒内多物料/多工单重复
|
||||
tsBase := time.Now().Format("20060102150405")
|
||||
seq := 1
|
||||
for _, g := range groups {
|
||||
for _, item := range g.bom {
|
||||
reqQty := float64(g.plan.PlanQty) * item.UnitQty * (1 + item.LossRate/100)
|
||||
if reqQty <= 0 {
|
||||
// 工位产量分配(日排产层):stationNo(字符串键) → 计划产量
|
||||
stationQty := map[int]int{}
|
||||
for k, v := range g.plan.StationPlanQty {
|
||||
if n, e := strconv.Atoi(k); e == nil {
|
||||
stationQty[n] = v
|
||||
}
|
||||
}
|
||||
// 成品可作零部件:BOM 子项若为成品,递归展开到叶子(原材料/外购件)再算料
|
||||
leaves := s.expandBomToLeaf(ctx, g.wo.ProductCode, bomNameOrDefault(""), 1, 0, map[string]bool{})
|
||||
for _, item := range leaves {
|
||||
pc := item.processCode // 该物料所属装配工序(1~12),0=不参与工位绑定
|
||||
if pc == 0 {
|
||||
reqQty := float64(g.plan.PlanQty) * item.unitQty * (1 + item.lossRate/100)
|
||||
if reqQty <= 0 {
|
||||
continue
|
||||
}
|
||||
reqNo := fmt.Sprintf("MR%s%03d", tsBase, seq)
|
||||
seq++
|
||||
_ = s.ctx.EntClient.MaterialRequest.Create().
|
||||
SetRequestNo(reqNo).SetOrderNo(g.plan.OrderNo).SetPlanDate(g.plan.PlanDate).
|
||||
SetMaterialCode(item.materialCode).SetMaterialName(item.materialName).
|
||||
SetUnit(item.unit).SetManageMode(item.manageMode).SetReqQty(reqQty).
|
||||
SetStatus("PENDING").SetOperator(operator).Exec(ctx)
|
||||
created++
|
||||
continue
|
||||
}
|
||||
reqNo := fmt.Sprintf("MR%s%03d", tsBase, seq)
|
||||
seq++
|
||||
// 送达接驳台:取该排产确认的 dock_codes(可多选,逗号分隔),未确认则为空串待仓管下发时指定
|
||||
target := strings.Join(g.plan.DockCodes, ",")
|
||||
_ = s.ctx.EntClient.MaterialRequest.Create().
|
||||
SetRequestNo(reqNo).SetOrderNo(g.plan.OrderNo).SetPlanDate(g.plan.PlanDate).
|
||||
SetMaterialCode(item.MaterialCode).SetMaterialName(item.MaterialName).
|
||||
SetUnit(item.Unit).SetManageMode(item.ManageMode).SetReqQty(reqQty).
|
||||
SetStatus("PENDING").SetOperator(operator).
|
||||
SetTargetDock(target).Exec(ctx)
|
||||
created++
|
||||
stations := stationsByProcess[pc]
|
||||
if len(stations) == 0 {
|
||||
// 该工序今日未派工到任何工位,本日无需上料(非缺料)
|
||||
continue
|
||||
}
|
||||
qtys := splitQtyToStations(stations, g.plan.PlanQty, stationQty)
|
||||
for i, stNo := range stations {
|
||||
q := qtys[i]
|
||||
if q <= 0 {
|
||||
continue
|
||||
}
|
||||
reqQty := float64(q) * item.unitQty * (1 + item.lossRate/100)
|
||||
if reqQty <= 0 {
|
||||
continue
|
||||
}
|
||||
reqNo := fmt.Sprintf("MR%s%03d", tsBase, seq)
|
||||
seq++
|
||||
_ = s.ctx.EntClient.MaterialRequest.Create().
|
||||
SetRequestNo(reqNo).SetOrderNo(g.plan.OrderNo).SetPlanDate(g.plan.PlanDate).
|
||||
SetMaterialCode(item.materialCode).SetMaterialName(item.materialName).
|
||||
SetUnit(item.unit).SetManageMode(item.manageMode).SetReqQty(reqQty).
|
||||
SetStatus("PENDING").SetOperator(operator).
|
||||
SetStationNo(stNo).SetProcessCode(pc).SetTargetDock(dockByStation[stNo]).Exec(ctx)
|
||||
created++
|
||||
}
|
||||
}
|
||||
}
|
||||
s.ctx.EventLog.Write(ctx, "material.request.generate", "", operator, "material_request", planDate, "按日排产自动生成备料单", map[string]any{"planDate": planDate, "count": created})
|
||||
s.ctx.EventLog.Write(ctx, "material.request.generate", "", operator, "material_request", planDate, "按日排产+工位派工自动生成工位级备料单", map[string]any{"planDate": planDate, "count": created})
|
||||
s.notifyDashboard()
|
||||
return created, nil, nil
|
||||
}
|
||||
|
||||
// bomLeaf 递归展开后的叶子物料行(原材料/外购件)
|
||||
type bomLeaf struct {
|
||||
materialCode string
|
||||
materialName string
|
||||
unit string
|
||||
manageMode string
|
||||
unitQty float64 // 已折算父级倍率的单台用量
|
||||
lossRate float64
|
||||
processCode int
|
||||
}
|
||||
|
||||
// expandBomToLeaf 递归展开产品物料清单到叶子(原材料/外购件)。
|
||||
//
|
||||
// 业务口径:成品可以作为零部件被另一个成品再次加工(多级嵌套)。备料算料时必须把
|
||||
// 成品子项按其自身的物料清单继续展开,直到原材料/外购件,否则产线会缺料。
|
||||
// 用量按倍率相乘(父级单台用量 × 子级单台用量),损耗率取路径上的最大值(保守备料)。
|
||||
//
|
||||
// 防环:同一条展开路径上出现重复编码视为配置错误,跳过该分支并记日志,避免死循环。
|
||||
// 降级:WMS 不可达时按"全部为叶子物料"处理(不递归),不阻塞备料生成。
|
||||
func (s *Service) expandBomToLeaf(ctx context.Context, productCode, bomName string, mult float64, depth int, path map[string]bool) []bomLeaf {
|
||||
const maxDepth = 10
|
||||
if depth > maxDepth || productCode == "" {
|
||||
return nil
|
||||
}
|
||||
bom, err := s.ListBom(ctx, productCode, bomName)
|
||||
if err != nil || len(bom) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 一次性取本层所有子项的品类,判断哪些是成品(需继续展开)
|
||||
codes := make([]string, 0, len(bom))
|
||||
for _, b := range bom {
|
||||
if b.MaterialCode != "" {
|
||||
codes = append(codes, b.MaterialCode)
|
||||
}
|
||||
}
|
||||
types := map[string]int{}
|
||||
if s.ctx.Wms != nil {
|
||||
if t, e := s.ctx.Wms.MaterialTypes(ctx, codes); e == nil {
|
||||
types = t
|
||||
} else {
|
||||
s.ctx.EventLog.Write(ctx, "bom.expand.wms_skip", "", "", "product_bom", productCode,
|
||||
"WMS 物料品类查询不可达,本次按叶子物料展开", map[string]any{"error": e.Error()})
|
||||
}
|
||||
}
|
||||
out := make([]bomLeaf, 0, len(bom))
|
||||
for _, b := range bom {
|
||||
if b.MaterialCode == "" {
|
||||
continue
|
||||
}
|
||||
if path[b.MaterialCode] {
|
||||
s.ctx.EventLog.Write(ctx, "bom.expand.cycle", "", "", "product_bom", productCode,
|
||||
"产品物料清单存在循环引用,已跳过", map[string]any{"materialCode": b.MaterialCode, "depth": depth})
|
||||
continue
|
||||
}
|
||||
unitQty := b.UnitQty * mult
|
||||
if types[b.MaterialCode] == 3 { // 成品子项:继续向下展开
|
||||
path[b.MaterialCode] = true
|
||||
sub := s.expandBomToLeaf(ctx, b.MaterialCode, bomName, unitQty, depth+1, path)
|
||||
delete(path, b.MaterialCode)
|
||||
if len(sub) > 0 {
|
||||
out = append(out, sub...)
|
||||
continue
|
||||
}
|
||||
// 该成品没有自己的物料清单:按叶子处理,避免漏料
|
||||
}
|
||||
out = append(out, bomLeaf{
|
||||
materialCode: b.MaterialCode,
|
||||
materialName: b.MaterialName,
|
||||
unit: b.Unit,
|
||||
manageMode: b.ManageMode,
|
||||
unitQty: unitQty,
|
||||
lossRate: b.LossRate,
|
||||
processCode: b.ProcessCode,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitQtyToStations 把日总量 dayQty 拆分到 stations 各工位:
|
||||
// 优先使用 stationQty 显式分配(其和>0 时直接采用);否则平均分配(余数补到前面工位)。
|
||||
func splitQtyToStations(stations []int, dayQty int, stationQty map[int]int) []int {
|
||||
n := len(stations)
|
||||
out := make([]int, n)
|
||||
usePlan := true
|
||||
sum := 0
|
||||
for _, st := range stations {
|
||||
v, ok := stationQty[st]
|
||||
if !ok {
|
||||
usePlan = false
|
||||
break
|
||||
}
|
||||
sum += v
|
||||
}
|
||||
if usePlan && sum > 0 {
|
||||
for i, st := range stations {
|
||||
out[i] = stationQty[st]
|
||||
}
|
||||
return out
|
||||
}
|
||||
if n == 0 {
|
||||
return out
|
||||
}
|
||||
base := dayQty / n
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = base
|
||||
}
|
||||
for i := 0; i < dayQty-base*n; i++ {
|
||||
out[i]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListMaterialRequests 查询备料单
|
||||
func (s *Service) ListMaterialRequests(ctx context.Context, orderNo, planDate, status string) ([]*ent.MaterialRequest, error) {
|
||||
return s.listMaterialRequests(ctx, orderNo, planDate, status, "", "", "")
|
||||
func (s *Service) ListMaterialRequests(ctx context.Context, orderNo, planDate, status, stationNo string) ([]*ent.MaterialRequest, error) {
|
||||
return s.listMaterialRequests(ctx, orderNo, planDate, status, "", "", "", stationNo)
|
||||
}
|
||||
|
||||
// ListMaterialRequestsFiltered 管理页丰富筛选入口
|
||||
func (s *Service) ListMaterialRequestsFiltered(ctx context.Context, orderNo, planDate, status, materialCode, materialName, targetDock string) ([]*ent.MaterialRequest, error) {
|
||||
return s.listMaterialRequests(ctx, orderNo, planDate, status, materialCode, materialName, targetDock)
|
||||
func (s *Service) ListMaterialRequestsFiltered(ctx context.Context, orderNo, planDate, status, materialCode, materialName, targetDock, stationNo string) ([]*ent.MaterialRequest, error) {
|
||||
return s.listMaterialRequests(ctx, orderNo, planDate, status, materialCode, materialName, targetDock, stationNo)
|
||||
}
|
||||
|
||||
// listMaterialRequests 备料单查询(2026-09-08 丰富筛选+大小写不敏感)
|
||||
// materialCode/materialName/targetDock 可空;keyword 兜底匹配 备料单号/工单号/物料编码
|
||||
func (s *Service) listMaterialRequests(ctx context.Context, orderNo, planDate, status, materialCode, materialName, targetDock string) ([]*ent.MaterialRequest, error) {
|
||||
// materialCode/materialName/targetDock/stationNo 可空;keyword 兜底匹配 备料单号/工单号/物料编码
|
||||
func (s *Service) listMaterialRequests(ctx context.Context, orderNo, planDate, status, materialCode, materialName, targetDock, stationNo string) ([]*ent.MaterialRequest, error) {
|
||||
q := s.ctx.EntClient.MaterialRequest.Query()
|
||||
if orderNo != "" {
|
||||
q = q.Where(materialrequest.OrderNoEqualFold(orderNo))
|
||||
@@ -133,9 +310,48 @@ func (s *Service) listMaterialRequests(ctx context.Context, orderNo, planDate, s
|
||||
if targetDock != "" {
|
||||
q = q.Where(materialrequest.TargetDockEqualFold(targetDock))
|
||||
}
|
||||
if stationNo != "" {
|
||||
if n, e := strconv.Atoi(stationNo); e == nil {
|
||||
q = q.Where(materialrequest.StationNo(n))
|
||||
}
|
||||
}
|
||||
return q.Order(ent.Desc(materialrequest.FieldCreatedAt), ent.Desc(materialrequest.FieldID)).All(ctx)
|
||||
}
|
||||
|
||||
// ReceiveMaterialRequest 接料确认(两个入口共用:工位终端扫码接料 / MES 备料页人工点「已接料」)。
|
||||
// 累加 material_request.sentQty(封顶 reqQty,不超额);累计达 reqQty 自动置 DONE(送达完结)。
|
||||
// qty<=0 表示「确认本批全部剩余」(sentQty 直接补到 reqQty),适合扫码/一键接料;>0 为本次实收批次量。
|
||||
func (s *Service) ReceiveMaterialRequest(ctx context.Context, requestNo string, qty int, operator string) error {
|
||||
mr, err := s.ctx.EntClient.MaterialRequest.Query().
|
||||
Where(materialrequest.RequestNo(requestNo)).Only(ctx)
|
||||
if err != nil {
|
||||
return errors.New("备料单不存在")
|
||||
}
|
||||
add := qty
|
||||
if add <= 0 {
|
||||
add = int(mr.ReqQty) - int(mr.SentQty)
|
||||
if add < 0 {
|
||||
add = 0
|
||||
}
|
||||
}
|
||||
newSent := int(mr.SentQty) + add
|
||||
if newSent > int(mr.ReqQty) {
|
||||
newSent = int(mr.ReqQty)
|
||||
}
|
||||
upd := s.ctx.EntClient.MaterialRequest.UpdateOneID(mr.ID).
|
||||
SetSentQty(float64(newSent)).SetOperator(operator)
|
||||
if newSent >= int(mr.ReqQty) && mr.Status != "DONE" {
|
||||
upd.SetStatus("DONE")
|
||||
}
|
||||
if _, err = upd.Save(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
s.ctx.EventLog.Write(ctx, "material.request.receive", requestNo, operator, "material_request", requestNo,
|
||||
"接料确认", map[string]any{"add": add, "sentQty": newSent, "reqQty": int(mr.ReqQty)})
|
||||
s.notifyDashboard()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// SetMaterialRequestStatus 推进备料单状态(WMS 下发 AGV DELIVERING / 到位 DONE)
|
||||
func (s *Service) SetMaterialRequestStatus(ctx context.Context, requestNo, status string) error {
|
||||
|
||||
Reference in New Issue
Block a user