278 lines
9.5 KiB
Go
278 lines
9.5 KiB
Go
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
|
|||
|
|
}
|