package logic import ( "context" "errors" "fmt" "math" "sort" "strconv" "strings" "time" "bj_power_mes/ent" "bj_power_mes/ent/materialrequest" "bj_power_mes/ent/station" "bj_power_mes/ent/workorder" "bj_power_mes/internal/wmsclient" ) // 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}) // 需求10:把每个工单的「工单级需求总量」同步给 WMS 台账(幂等,失败降级不阻塞)。 // 必须先同步 total_qty,后续自动出库才能在台账上累加 out_qty 并判定缺口/领料完结。 synced := map[string]bool{} for _, g := range groups { if synced[g.plan.OrderNo] { continue } synced[g.plan.OrderNo] = true if e := s.SyncOrderLedger(ctx, g.plan.OrderNo, operator); e != nil { s.ctx.EventLog.Write(ctx, "material.ledger.sync_fail", "", operator, "material_request", g.plan.OrderNo, "工单需求量同步 WMS 台账失败", map[string]any{"orderNo": g.plan.OrderNo, "error": e.Error()}) } } // 生成后立即自动出库(客户第1条:平时无需操作)。缺料/失败不阻塞生成,仅记预警等待补库存。 if _, short := s.AutoOutboundRequests(ctx, planDate, "", operator); len(short) > 0 { s.ctx.EventLog.Write(ctx, "material.outbound.short", "", operator, "material_request", planDate, "备料自动出库未齐套", map[string]any{"planDate": planDate, "short": strings.Join(short, "; ")}) } s.notifyDashboard() return created, nil, nil } // SyncOrderLedger 计算并向 WMS 下发某工单的「工单级物料需求总量」(需求10)。 // // 需求量口径(临时.md §四 工单域):需求量 = 工单数量 × 单台用量 × (1+损耗率),向上取整。 // 工单级而非日级——用日级会误卡跨天生产(临时.md L283-285)。 // 多级 BOM 展开到叶子后按物料编码聚合(同编码合并),一个物料一行下发。 // WMS 侧按 orderNo+materialCode 幂等 upsert total_qty,可重复调用。 func (s *Service) SyncOrderLedger(ctx context.Context, orderNo, operator string) error { wo, err := s.ctx.EntClient.WorkOrder.Query(). Where(workorder.WorkOrderNo(orderNo)).Only(ctx) if err != nil { return err } leaves := s.expandBomToLeaf(ctx, wo.ProductCode, bomNameOrDefault(""), 1, 0, map[string]bool{}) if len(leaves) == 0 { return nil } type agg struct { name string qty float64 } m := map[string]*agg{} order := make([]string, 0, len(leaves)) for _, it := range leaves { if it.materialCode == "" { continue } need := float64(wo.Quantity) * it.unitQty * (1 + it.lossRate/100) if need <= 0 { continue } if a, ok := m[it.materialCode]; ok { a.qty += need } else { m[it.materialCode] = &agg{name: it.materialName, qty: need} order = append(order, it.materialCode) } } items := make([]wmsclient.LedgerSyncItem, 0, len(order)) for _, code := range order { a := m[code] items = append(items, wmsclient.LedgerSyncItem{ MaterialCode: code, MaterialName: a.name, TotalQty: int(math.Ceil(a.qty - 1e-9)), }) } if len(items) == 0 { return nil } return s.ctx.Wms.LedgerSync(ctx, orderNo, wo.ProductCode, operator, items) } // AutoOutboundRequests 工单备料自动出库(客户第1条:备料出库根据 MES 排产自动生成,平时无需操作)。 // // 对指定排产日 / 工单下的备料单,按 FIFO 从 WMS「合格」在库库存自动领料: // - 结构件(manageMode=1):按批次号升序逐批出库,直到补足未出数量; // - 电气件(manageMode=2):按可用 SN 逐件出库。 // // 幂等:只出「未出数量 = 需求量 − 已发数量」,重复调用不会超领。 // 库存不足的行已出部分照常计入 sentQty,差额维持待备料并触发「备料未齐套」预警, // 库存补齐后再次调用即可补出(补出由 WMS 台账强约束兜底,不会超出工单 BOM 需求)。 // 返回:本次补足(出库总量达标)的备料单条数、未出齐的明细文案。 func (s *Service) AutoOutboundRequests(ctx context.Context, planDate, orderNo, operator string) (int, []string) { list, err := s.ListMaterialRequests(ctx, MaterialRequestQuery{OrderNo: orderNo, PlanDate: planDate}) if err != nil { return 0, []string{"查询备料单失败: " + err.Error()} } // 补料单(refill)优先于排产备料(plan):先把补料发出,避免产线因缺料停线。 sort.SliceStable(list, func(i, j int) bool { return list[i].Source == "REFILL" && list[j].Source != "REFILL" }) done := 0 short := make([]string, 0) for _, mr := range list { need := int(mr.ReqQty - mr.SentQty + 0.5) if need <= 0 { continue } batches, sns, err := s.ctx.Wms.StockBatches(ctx, mr.MaterialCode) if err != nil { short = append(short, fmt.Sprintf("%s %s 查询 WMS 库存失败", mr.OrderNo, mr.MaterialCode)) continue } dock := mr.TargetDock if dock == "" { dock = "DOCK01" } remain := need outboundNos := make([]string, 0, 4) // 出库参数:补料(source=REFILL) 记为 refill 类型并默认叫车,且携带超领原因 // (补料常因损耗/报废超出 BOM 需求,WMS 侧有原因才放行)。 obType := "workorder" if mr.Source == "REFILL" { obType = "refill" } opts := wmsclient.StockDeductOpts{ Qty: need, Operator: operator, TargetDock: dock, OutboundType: obType, NeedAgv: mr.NeedAgv, OverReason: mr.OverReason, } if mr.ManageMode == "2" { for _, sn := range sns { if remain <= 0 { break } opts.SnList = []string{sn} opts.BatchNo = "" opts.Qty = 1 no, _, e := s.ctx.Wms.StockDeduct(ctx, mr.OrderNo, mr.MaterialCode, opts) if e != nil { break } outboundNos = append(outboundNos, no) remain-- } } else { for _, b := range batches { if remain <= 0 { break } take := b.Avail if take > remain { take = remain } if take <= 0 { continue } opts.SnList = nil opts.BatchNo = b.BatchNo opts.Qty = take opts.ZoneCode = b.ZoneCode no, _, e := s.ctx.Wms.StockDeduct(ctx, mr.OrderNo, mr.MaterialCode, opts) if e != nil { break } outboundNos = append(outboundNos, no) remain -= take } } sent := need - remain if sent > 0 { upd := s.ctx.EntClient.MaterialRequest.UpdateOneID(mr.ID).AddSentQty(float64(sent)) if remain == 0 { // 出库量已达标:备料完成,等 AGV 配送(WMS 下发后回写 DELIVERING) upd = upd.SetStatus("LOCKED") done++ } if _, e := upd.Save(ctx); e == nil { s.ctx.EventLog.Write(ctx, "material.outbound.auto", mr.OrderNo, operator, "material_request", mr.RequestNo, "备料自动出库", map[string]any{"materialCode": mr.MaterialCode, "qty": sent, "outboundNos": strings.Join(outboundNos, ",")}) } } if remain > 0 { short = append(short, fmt.Sprintf("%s %s 缺 %d", mr.OrderNo, mr.MaterialCode, remain)) s.Evaluate(ctx, "material_shortage", 1, "备料未齐套", fmt.Sprintf("工单 %s 物料 %s 自动出库后仍缺 %d(已出 %d/%d)", mr.OrderNo, mr.MaterialCode, remain, sent, need), "material_request", mr.RequestNo, mr.OrderNo) } } if done > 0 { s.notifyDashboard() } return done, short } // bomLeaf 递归展开后的叶子物料行(原材料/外购件) type bomLeaf struct { materialCode string materialName string unit string manageMode string unitQty float64 // 已折算父级倍率的单台用量 lossRate float64 processCode int } // expandBomToLeaf 递归展开产品物料清单到叶子(原材料/外购件),供备料算料。 // // 业务口径:成品可以作为零部件被另一个成品再次加工(多级嵌套)。备料算料时必须把 // 成品子项按其自身的物料清单继续展开,直到原材料/外购件,否则产线会缺料。 // 用量按倍率相乘(父级单台用量 × 子级单台用量)。 // // R1(2026-09-19):递归骨架统一到 bom_expand.go 的 expandBomTree,与追溯侧 // (expandBindBom)共用同一份实现;下钻判定由 wmsFinishedResolver 注入, // WMS 不可达时按叶子处理,不阻塞备料生成。 func (s *Service) expandBomToLeaf(ctx context.Context, productCode, bomName string, mult float64, depth int, path map[string]bool) []bomLeaf { return bomTreeToLeaf(s.expandBomTree(ctx, productCode, bomName, mult, depth, path, s.wmsFinishedResolver)) } // 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 } // MaterialRequestQuery 备料单查询条件(零值=不过滤)。 // 统一入口:原 ListMaterialRequests / ListMaterialRequestsFiltered 两套位置参数合并到本结构, // 避免参数越加越长、调用点易错。 type MaterialRequestQuery struct { OrderNo string PlanDate string Status string MaterialCode string MaterialName string TargetDock string StationNo string Source string // PLAN / REFILL RequestNo string // 备料单号(独立模糊筛选;列表页禁用关键字混搜) } // ListMaterialRequests 备料单查询(大小写不敏感)。 func (s *Service) ListMaterialRequests(ctx context.Context, o MaterialRequestQuery) ([]*ent.MaterialRequest, error) { q := s.ctx.EntClient.MaterialRequest.Query() if o.OrderNo != "" { q = q.Where(materialrequest.OrderNoEqualFold(o.OrderNo)) } if o.PlanDate != "" { q = q.Where(materialrequest.PlanDate(o.PlanDate)) } if o.Status != "" { q = q.Where(materialrequest.Status(o.Status)) } if o.MaterialCode != "" { q = q.Where(materialrequest.MaterialCodeContainsFold(o.MaterialCode)) } if o.MaterialName != "" { q = q.Where(materialrequest.MaterialNameContainsFold(o.MaterialName)) } if o.TargetDock != "" { q = q.Where(materialrequest.TargetDockEqualFold(o.TargetDock)) } if o.Source != "" { q = q.Where(materialrequest.Source(o.Source)) } if o.RequestNo != "" { q = q.Where(materialrequest.RequestNoContainsFold(o.RequestNo)) } if o.StationNo != "" { if n, e := strconv.Atoi(o.StationNo); e == nil { q = q.Where(materialrequest.StationNo(n)) } } return q.Order(ent.Desc(materialrequest.FieldCreatedAt), ent.Desc(materialrequest.FieldID)).All(ctx) } // ReceiveMaterialRequest 接料确认(两个入口共用:工位终端扫码接料 / MES 备料页人工点「已接料」)。 // // 字段口径(2026-09-19 定稿):写 receivedQty(已接料),**不再累加 sentQty**。 // // sentQty 已发出量 [写者:自动出库] —— 幂等基准「还需发多少 = reqQty − sentQty」 // receivedQty 已接料量 [写者:本方法] —— 在途 = sentQty − receivedQty // // 上限 = 已发出量(工位只能接到仓库已发出来的料),不超接。 // qty<=0 表示「确认本批全部剩余」,适合扫码/一键接料;>0 为本次实收批次量。 // 累计接到 sentQty 且已发齐 reqQty → 状态置 DONE(送达并与接料双完结)。 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("备料单不存在") } sent := int(mr.SentQty) already := int(mr.ReceivedQty) pending := sent - already // 已发出但尚未接料(在途) if pending < 0 { pending = 0 } add := qty if add <= 0 { add = pending // 确认本批全部剩余 } if add > pending { add = pending // 不超接:上限是仓库已发出的量 } if add < 0 { add = 0 } newReceived := already + add upd := s.ctx.EntClient.MaterialRequest.UpdateOneID(mr.ID). SetReceivedQty(float64(newReceived)). SetReceivedAt(time.Now().Unix()). SetReceiveBy(operator) if mr.Status != "DONE" && sent >= int(mr.ReqQty) && newReceived >= sent { 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, "receivedQty": newReceived, "sentQty": sent, "reqQty": int(mr.ReqQty), "awaiting": sent - newReceived}) 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 }