Files
bj_power/bj_power_mes/internal/logic/qty_report.go
T

278 lines
9.5 KiB
Go
Raw Normal View History

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
}