package logic import ( "context" "errors" "fmt" "strconv" "time" "bj_power_mes/ent" "bj_power_mes/ent/alert" "bj_power_mes/ent/materialqtyreport" "bj_power_mes/ent/station" ) // ============ 数量不符(工位上报 → 处理闭环) ============ // // 归属(2026-09-19 定稿):**MES 为唯一真源与处理侧**。 // 上报链(工位终端 → MES /material_qty_report)、表、预警全在 MES, // 因此处理动作也放 MES,形成"上报 → 处理 → 生成对应单据"的完整闭环; // 不在 WMS 另存一份明细(WMS 只经内部接口被动建单)。 // // 处理动作与生成单据: // 补发 REPLENISH → 少发(diffQty>0)时生成 source=REFILL 的补料单,走既有自动出库 + AGV // 退库 RETURN → 多发(diffQty<0)时调 WMS 建退库单(工位退料回库房同一条链路) // 调整 ADJUST → 少量差异双方确认后直接关闭(不补不退) // QtyReportQuery 数量不符记录查询条件(全部可选,多条件取交集) type QtyReportQuery struct { Status string StationNo string MaterialCode string OrderNo string Page int PageSize int } // QtyReportRow 数量不符记录读模型 type QtyReportRow struct { ID int `json:"id"` ReportNo string `json:"reportNo"` // 业务单号 QR{id}(对外展示与追溯用) StationNo int `json:"stationNo"` OrderNo string `json:"orderNo"` Sn string `json:"sn"` MaterialCode string `json:"materialCode"` MaterialName string `json:"materialName"` PlanQty int `json:"planQty"` ActualQty int `json:"actualQty"` DiffQty int `json:"diffQty"` Cause string `json:"cause"` Status string `json:"status"` HandleType string `json:"handleType"` HandleDocNo string `json:"handleDocNo"` HandleNote string `json:"handleNote"` HandledBy string `json:"handledBy"` HandledAt int64 `json:"handledAt"` Operator string `json:"operator"` CreatedAt int64 `json:"createdAt"` } // qtyReportNo 差异单对外业务单号(与预警 refId 保持一致,便于从预警一键定位) func qtyReportNo(id int) string { return fmt.Sprintf("QR%d", id) } // ListQtyReports 数量不符记录查询(真分页:count + 分页查询均在 SQL 层)。 func (s *Service) ListQtyReports(ctx context.Context, q QtyReportQuery) (map[string]any, error) { if q.Page < 1 { q.Page = 1 } if q.PageSize < 1 || q.PageSize > 200 { q.PageSize = 20 } b := s.ctx.EntClient.MaterialQtyReport.Query() if q.Status != "" { // RESOLVED 为历史值,读时归并为 CLOSED if q.Status == "CLOSED" { b = b.Where(materialqtyreport.StatusIn("CLOSED", "RESOLVED")) } else { b = b.Where(materialqtyreport.Status(q.Status)) } } if q.StationNo != "" { if n, e := strconv.Atoi(q.StationNo); e == nil { b = b.Where(materialqtyreport.StationNo(n)) } } if q.MaterialCode != "" { b = b.Where(materialqtyreport.MaterialCodeContainsFold(q.MaterialCode)) } if q.OrderNo != "" { b = b.Where(materialqtyreport.OrderNoEqualFold(q.OrderNo)) } total, err := b.Count(ctx) if err != nil { return nil, err } list, err := b.Order(ent.Desc(materialqtyreport.FieldCreatedAt), ent.Desc(materialqtyreport.FieldID)). Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).All(ctx) if err != nil { return nil, err } rows := make([]QtyReportRow, 0, len(list)) for _, r := range list { rows = append(rows, QtyReportRow{ ID: r.ID, ReportNo: qtyReportNo(r.ID), StationNo: r.StationNo, OrderNo: r.OrderNo, Sn: r.Sn, MaterialCode: r.MaterialCode, MaterialName: r.MaterialName, PlanQty: r.PlanQty, ActualQty: r.ActualQty, DiffQty: r.DiffQty, Cause: r.Cause, Status: r.Status, HandleType: r.HandleType, HandleDocNo: r.HandleDocNo, HandleNote: r.HandleNote, HandledBy: r.HandledBy, HandledAt: r.HandledAt, Operator: r.Operator, CreatedAt: r.CreatedAt.Unix(), }) } return map[string]any{"total": total, "list": rows, "page": q.Page, "pageSize": q.PageSize}, nil } // QtyReportStats 数量不符统计(顶部概览:待处理 / 已补发 / 已退库 / 已关闭) func (s *Service) QtyReportStats(ctx context.Context) (map[string]any, error) { pending, err := s.ctx.EntClient.MaterialQtyReport.Query(). Where(materialqtyreport.StatusIn("PENDING", "PROCESSING")).Count(ctx) if err != nil { return nil, err } replenished, _ := s.ctx.EntClient.MaterialQtyReport.Query(). Where(materialqtyreport.Status("REPLENISHED")).Count(ctx) returned, _ := s.ctx.EntClient.MaterialQtyReport.Query(). Where(materialqtyreport.Status("RETURNED")).Count(ctx) closed, _ := s.ctx.EntClient.MaterialQtyReport.Query(). Where(materialqtyreport.StatusIn("CLOSED", "ADJUSTED", "RESOLVED")).Count(ctx) return map[string]any{ "pending": pending, "replenished": replenished, "returned": returned, "closed": closed, }, nil } // HandleQtyReport 处理数量不符(唯一处理入口)。 // // handleType: // // REPLENISH 补发(仅少发可用):生成 source=REFILL 的补料单,needAgv=true, // 关联差异单号;后续走既有「自动出库 → AGV → 接料」链路,本单即闭环。 // RETURN 退库(仅多发可用):调 WMS 建退库单(与工位退料同链路),WMS 确认收货后库存加回。 // ADJUST 调整关闭(任意方向):少量差异双方确认,不补不退,必须填说明。 // // 幂等:仅 PENDING/PROCESSING 可处理,已处理单据拒绝重复处理。 func (s *Service) HandleQtyReport(ctx context.Context, id int, handleType, note, operator string) (map[string]any, error) { if id <= 0 { return nil, errors.New("缺少差异单ID") } rep, err := s.ctx.EntClient.MaterialQtyReport.Get(ctx, id) if err != nil { return nil, errors.New("数量不符记录不存在") } if rep.Status != "PENDING" && rep.Status != "PROCESSING" { return nil, errors.New("该差异已处理(" + qtyReportStatusLabel(rep.Status) + "),不能重复处理") } docNo := "" switch handleType { case "REPLENISH": if rep.DiffQty <= 0 { return nil, errors.New("本单为「多发」,不能走补发;请选择退库或调整") } // 目标接驳台:取该工位绑定的接驳台(无则空,由 WMS 侧按默认处理) dock := "" if st, e := s.ctx.EntClient.Station.Query(). Where(station.StationNo(rep.StationNo)).Only(ctx); e == nil { dock = st.DockCode } reqNo := fmt.Sprintf("MR%d", time.Now().UnixNano()) create := s.ctx.EntClient.MaterialRequest.Create(). SetRequestNo(reqNo). SetOrderNo(rep.OrderNo). SetPlanDate(time.Now().Format("2006-01-02")). SetMaterialCode(rep.MaterialCode). SetMaterialName(rep.MaterialName). SetReqQty(float64(rep.DiffQty)). SetStatus("PENDING"). SetStationNo(rep.StationNo). SetSource("REFILL"). SetNeedAgv(true). SetRefReportNo(qtyReportNo(rep.ID)). SetOperator(operator) if dock != "" { create = create.SetTargetDock(dock) } if _, e := create.Save(ctx); e != nil { return nil, fmt.Errorf("生成补料单失败:%w", e) } docNo = reqNo case "RETURN": if rep.DiffQty >= 0 { return nil, errors.New("本单为「少发」,不能走退库;请选择补发或调整") } sendQty := -rep.DiffQty // 多发量 if s.ctx.Wms == nil { return nil, errors.New("WMS 未配置,无法生成退库单") } if e := s.ctx.Wms.CreateReturnOrder(ctx, rep.OrderNo, rep.Sn, rep.MaterialCode, rep.MaterialName, "", "数量不符多发退回", operator, rep.StationNo, sendQty, ""); e != nil { return nil, fmt.Errorf("生成退库单失败:%w", e) } docNo = fmt.Sprintf("RO-%s", qtyReportNo(rep.ID)) case "ADJUST": if note == "" { return nil, errors.New("调整关闭必须填写处理说明") } default: return nil, errors.New("非法处理方式(仅支持 补发/退库/调整)") } newStatus := map[string]string{ "REPLENISH": "REPLENISHED", "RETURN": "RETURNED", "ADJUST": "CLOSED", }[handleType] if _, err := s.ctx.EntClient.MaterialQtyReport.UpdateOneID(rep.ID). SetStatus(newStatus). SetHandleType(handleType). SetHandleDocNo(docNo). SetHandleNote(note). SetHandledBy(operator). SetHandledAt(time.Now().Unix()). Save(ctx); err != nil { return nil, err } // 闭环预警:把本差异单对应的未读「数量不符」预警标记为已处理, // 避免已处理完的差异仍在预警中心反复提醒。 _, _ = s.ctx.EntClient.Alert.Update(). Where(alert.Type("qty_diff"), alert.RefId(qtyReportNo(rep.ID)), alert.Status("UNREAD")). SetStatus("READ").Save(ctx) s.ctx.EventLog.Write(ctx, "qty_report.handle", rep.OrderNo, operator, "material_qty_report", qtyReportNo(rep.ID), "数量不符处理:"+qtyReportStatusLabel(newStatus)+ fmt.Sprintf(" 物料 %s 差异 %d", rep.MaterialCode, rep.DiffQty), map[string]any{"handleType": handleType, "handleDocNo": docNo, "note": note, "stationNo": rep.StationNo, "materialCode": rep.MaterialCode, "diffQty": rep.DiffQty}) s.notifyDashboard() return map[string]any{ "id": rep.ID, "status": newStatus, "handleType": handleType, "handleDocNo": docNo, }, nil } // qtyReportStatusLabel 状态中文标签(界面禁英文,统一在此转换) func qtyReportStatusLabel(s string) string { switch s { case "PENDING": return "待处理" case "PROCESSING": return "处理中" case "REPLENISHED": return "已补发" case "RETURNED": return "已退库" case "ADJUSTED", "CLOSED", "RESOLVED": return "已关闭" } return s }