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 }