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

371 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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 logic
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"bj_power_mes/ent"
"bj_power_mes/ent/materialrequest"
"bj_power_mes/ent/station"
"bj_power_mes/ent/workorder"
)
// 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)。
func (s *Service) GenerateMaterialRequest(ctx context.Context, planDate string, operator string) (int, []string, error) {
plans, err := s.ListDailyPlans(ctx, "", planDate)
if err != nil {
return 0, nil, err
}
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
bom []*ent.BomItem
}
groups := make([]planBom, 0, len(plans))
codeSet := map[string]bool{}
for _, plan := range plans {
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 {
continue
}
bom, err := s.ListBom(ctx, wo.ProductCode, bomNameOrDefault(""))
if err != nil {
continue
}
if len(bom) == 0 {
continue
}
for _, item := range bom {
if item.MaterialCode != "" {
codeSet[item.MaterialCode] = true
}
}
groups = append(groups, planBom{plan: plan, wo: wo, bom: bom})
}
if len(groups) == 0 {
return 0, nil, errors.New("该日期排产均无有效 BOM 物料,无法生成备料单")
}
// 阶段1:WMS 物料存在性校验(全部存在才生成)
codes := make([]string, 0, len(codeSet))
for c := range codeSet {
codes = append(codes, c)
}
missing, wmsErr := s.ctx.Wms.MaterialExists(ctx, codes)
if wmsErr != nil {
// 降级:WMS 不可达按"全部存在"放行,记日志供事后核查
s.ctx.EventLog.Write(ctx, "material.request.wms_skip", "", operator, "material_request", planDate,
"WMS 物料校验不可达,已降级放行", map[string]any{"planDate": planDate, "error": wmsErr.Error()})
} else if len(missing) > 0 {
return 0, missing, nil
}
// 阶段2:全部存在 → 按工位拆料生成
created := 0
tsBase := time.Now().Format("20060102150405")
seq := 1
for _, g := range groups {
// 工位产量分配(日排产层):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
}
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.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, 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, stationNo string) ([]*ent.MaterialRequest, error) {
return s.listMaterialRequests(ctx, orderNo, planDate, status, materialCode, materialName, targetDock, stationNo)
}
// listMaterialRequests 备料单查询(2026-09-08 丰富筛选+大小写不敏感)
// 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))
}
if planDate != "" {
q = q.Where(materialrequest.PlanDate(planDate))
}
if status != "" {
q = q.Where(materialrequest.Status(status))
}
if materialCode != "" {
q = q.Where(materialrequest.MaterialCodeContainsFold(materialCode))
}
if materialName != "" {
q = q.Where(materialrequest.MaterialNameContainsFold(materialName))
}
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 {
n, err := s.ctx.EntClient.MaterialRequest.Update().
Where(materialrequest.RequestNo(requestNo)).
SetStatus(status).
Save(ctx)
if err != nil {
return err
}
if n == 0 {
return errors.New("备料单不存在")
}
s.notifyDashboard()
return nil
}