1. 新增预警表添加工单号字段,支持按工单号检索预警 2. 添加工单过滤在制品/绩效报表/预警中心查询 3. 补全WMS台账字段口径与权限配置 4. 修复备料单与数量不符处理流程 5. 新增配送进度/库位联动/自动出库功能 6. 修复AGV配送状态更新逻辑 7. 优化退料与库存管理细节
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
|
||
}
|