Files
bj_power/bj_power_mes/internal/logic/qty_report.go
T
SunYF 05b0b8a909 feat: 完成客户第12条需求+多系统改造
1. 新增预警表添加工单号字段,支持按工单号检索预警
2. 添加工单过滤在制品/绩效报表/预警中心查询
3. 补全WMS台账字段口径与权限配置
4. 修复备料单与数量不符处理流程
5. 新增配送进度/库位联动/自动出库功能
6. 修复AGV配送状态更新逻辑
7. 优化退料与库存管理细节
2026-09-19 17:04:58 +08:00

278 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}