package outbound import ( "context" "errors" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata" "strings" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/packagecustomer" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gtime" ) type AsyncMessage struct { BatchNo string `json:"batchNo"` } var errOutboundValidationRejected = errors.New("存在不满足出库条件的卡,本次未执行") const outboundNotExecutedReason = "校验通过,但本批次存在失败明细,整批未执行出库" func SubmitExcelAsync(ctx context.Context, file *ghttp.UploadFile, in *model.TSimCardOutboundExcelSubmitParam) (*batchimport.Result, error) { operator := batchimport.OperatorContext{TenantId: contexts.GetTenantId(ctx), DeptId: contexts.GetDeptId(ctx), UserId: contexts.GetUserId(ctx)} processor := NewProcessor(PreviewOptions{DeptId: operator.DeptId}) result, err := batchimport.CreatePendingUpload(ctx, batchimport.SubmitRequest{ SourceChannel: batchmetadata.Console, ExecutionMode: batchmetadata.Bulk, File: file, BizParams: in, SourceType: consts.BatchSourceTypeFile, Operator: operator, }, processor, batchimport.PendingOptions{}) if err != nil { return nil, err } return result, publishAsync(ctx, result.BatchNo) } func SubmitRowsAsync(ctx context.Context, in any, rows []batchimport.Row, sourceType int, sourceBatchNo string, strict bool) (*batchimport.Result, error) { operator := batchimport.OperatorContext{TenantId: contexts.GetTenantId(ctx), DeptId: contexts.GetDeptId(ctx), UserId: contexts.GetUserId(ctx)} processor := NewProcessor(PreviewOptions{DeptId: operator.DeptId, StrictStockIdentity: strict}) result, err := batchimport.CreatePendingRows(ctx, batchimport.SubmitRequest{ SourceChannel: batchmetadata.Console, ExecutionMode: batchmetadata.Bulk, BizParams: in, SourceType: sourceType, SourceBatchNo: sourceBatchNo, Operator: operator, }, processor, rows, batchimport.PendingOptions{}) if err != nil { return nil, err } return result, publishAsync(ctx, result.BatchNo) } func publishAsync(ctx context.Context, batchNo string) error { if strings.TrimSpace(batchNo) == "" { return gerror.New("出库批次号不能为空") } if !rabbitmq.Enabled(ctx) { err := gerror.New("RabbitMQ未启用,无法提交异步出库任务") if markErr := markAsyncFailed(ctx, batchNo, err); markErr != nil { return markErr } return err } if err := rabbitmq.Publish(ctx, consts.QueueBatchOutboundExecute, AsyncMessage{BatchNo: batchNo}); err != nil { publishErr := gerror.Wrap(err, "发布异步出库任务失败") if markErr := markAsyncFailed(ctx, batchNo, publishErr); markErr != nil { return markErr } return publishErr } return nil } func ConsumeAsync(ctx context.Context) error { return rabbitmq.ConsumeJSON[AsyncMessage](ctx, consts.QueueBatchOutboundExecute, "iotlcm-admin-outbound", handleAsyncMessage) } func handleAsyncMessage(ctx context.Context, message AsyncMessage) error { if strings.TrimSpace(message.BatchNo) == "" { return nil } main := &struct { BatchNo string BatchType int BatchAsyncStatus int BatchInfo *gjson.Json SourceBatchNo string TenantId string CreatedDept int64 CreatedBy int64 TotalCount int }{} if err := dao.BatchMain.Ctx(ctx).Where(dao.BatchMain.Columns().BatchNo, message.BatchNo).Scan(main); err != nil { return err } if main.BatchNo == "" || main.BatchType != consts.BatchTypeOutbound { return nil } strictStockIdentity := false if main.BatchInfo != nil { strictStockIdentity = main.BatchInfo.Get("strictStockIdentity").Bool() } processor := NewProcessor(PreviewOptions{DeptId: main.CreatedDept, StrictStockIdentity: strictStockIdentity}) param, err := submitParamFromInfo(main.BatchInfo) if err != nil { return markAsyncFailed(ctx, message.BatchNo, err) } rows, err := loadAsyncRows(ctx, message.BatchNo) if err != nil { return markAsyncFailed(ctx, message.BatchNo, err) } operator := batchimport.OperatorContext{TenantId: main.TenantId, DeptId: main.CreatedDept, UserId: main.CreatedBy} requiredSource := "" if main.BatchInfo != nil { requiredSource = strings.TrimSpace(main.BatchInfo.Get("inboundBatchNo").String()) } var ( claimed bool output *CompleteOutput validationRejected bool ) err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { claimResult, claimErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()). Where(dao.BatchMain.Columns().BatchNo, message.BatchNo). Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusPending). Data(do.BatchMain{ BatchAsyncStatus: consts.BatchAsyncStatusProcessing, RunResult: consts.BatchRunResultRunning, StartTime: gtime.Now(), UpdatedBy: main.CreatedBy, }). Update() if claimErr != nil { return claimErr } claimCount, claimErr := claimResult.RowsAffected() if claimErr != nil { return gerror.Wrap(claimErr, "读取异步出库任务领取结果失败") } if claimCount != 1 { return nil } claimed = true if _, updateErr := tx.Ctx(ctx).Model(dao.BatchDetail.Table()). Where(dao.BatchDetail.Columns().BatchNo, message.BatchNo). Data(do.BatchDetail{DetailStatus: consts.BatchDetailStatusRunning, UpdatedBy: main.CreatedBy}). Update(); updateErr != nil { return updateErr } var executeErr error output, executeErr = processor.ExecutePending(ctx, tx, message.BatchNo, param, rows, operator, requiredSource) if executeErr != nil { validationRejected = errors.Is(executeErr, errOutboundValidationRejected) return executeErr } if _, updateErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where(dao.BatchMain.Columns().BatchNo, message.BatchNo).Data(do.BatchMain{ OrderSn: output.OrderSn, BatchAsyncStatus: consts.BatchAsyncStatusSuccess, RunResult: consts.BatchRunResultSuccess, SuccessCount: output.Quantity, ValidCount: output.Quantity, FailCount: 0, EndTime: gtime.Now(), UpdatedBy: main.CreatedBy, }).Update(); updateErr != nil { return updateErr } if _, updateErr := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).Where(dao.BatchDetail.Columns().BatchNo, message.BatchNo).Data(do.BatchDetail{ DetailStatus: consts.BatchDetailStatusSuccess, ErrorCode: "", ErrorReason: "", UpdatedBy: main.CreatedBy, }).Update(); updateErr != nil { return updateErr } return nil }) if err != nil { if validationRejected { return markAsyncValidationFailed(ctx, message.BatchNo, main.TenantId, rows, err) } return markAsyncFailed(ctx, message.BatchNo, err) } if !claimed { return nil } if output != nil && output.Customer != nil && output.Customer.DeptId > 0 { packagecustomer.PublishSyncSafe(ctx, model.PackageCustomerSyncMessage{ TenantId: operator.TenantId, DeptId: output.Customer.DeptId, UserId: operator.UserId, Trigger: "outbound", SourceBatchNo: output.BatchNo, }) } return nil } func markAsyncValidationFailed(ctx context.Context, batchNo, tenantID string, rows []*Row, cause error) error { validCount, failCount := 0, 0 for _, row := range rows { if row == nil { continue } if row.Reason == outboundNotExecutedReason { validCount++ } else { failCount++ } } return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { for _, row := range rows { if row == nil { continue } status := consts.BatchDetailStatusFailed if row.Reason == outboundNotExecutedReason { status = consts.BatchDetailStatusNotExecuted } if _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()). Where(dao.BatchDetail.Columns().BatchNo, batchNo). Where(dao.BatchDetail.Columns().TenantId, tenantID). Where(dao.BatchDetail.Columns().RowNo, row.RowNo). Data(do.BatchDetail{DetailStatus: status, ErrorReason: row.Reason}).Update(); err != nil { return gerror.Wrap(err, "保存出库校验明细失败") } } _, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()). Where(dao.BatchMain.Columns().BatchNo, batchNo). Where(dao.BatchMain.Columns().TenantId, tenantID). Data(do.BatchMain{BatchAsyncStatus: consts.BatchAsyncStatusFailed, RunResult: consts.BatchRunResultFailed, ValidCount: validCount, SuccessCount: 0, FailCount: failCount, FailReason: asyncFailureReason(cause), EndTime: gtime.Now()}).Update() return gerror.Wrap(err, "保存出库校验结果失败") }) } func markAsyncFailed(ctx context.Context, batchNo string, err error) error { reason := asyncFailureReason(err) if _, updateErr := g.DB().Model(dao.BatchMain.Table()).Ctx(ctx).Where(dao.BatchMain.Columns().BatchNo, batchNo).Data(do.BatchMain{ BatchAsyncStatus: consts.BatchAsyncStatusFailed, RunResult: consts.BatchRunResultFailed, ValidCount: 0, SuccessCount: 0, FailCount: gdb.Raw(dao.BatchMain.Columns().TotalCount), FailReason: reason, EndTime: gtime.Now(), }).Update(); updateErr != nil { return gerror.Wrap(updateErr, reason) } if _, updateErr := g.DB().Model(dao.BatchDetail.Table()).Ctx(ctx).Where(dao.BatchDetail.Columns().BatchNo, batchNo).Data(do.BatchDetail{ DetailStatus: consts.BatchDetailStatusFailed, ErrorReason: reason, }).Update(); updateErr != nil { return gerror.Wrap(updateErr, reason) } return nil } func loadAsyncRows(ctx context.Context, batchNo string) ([]*Row, error) { var details []*struct { RowNo int ItemId string Snapshot *gjson.Json } if err := dao.BatchDetail.Ctx(ctx).Fields(dao.BatchDetail.Columns().RowNo, dao.BatchDetail.Columns().ItemId, dao.BatchDetail.Columns().Snapshot).Where(dao.BatchDetail.Columns().BatchNo, batchNo).OrderAsc(dao.BatchDetail.Columns().RowNo).Scan(&details); err != nil { return nil, err } rows := make([]*Row, 0, len(details)) for _, detail := range details { if detail == nil { continue } row := &Row{RowNo: detail.RowNo, Iccid: strings.TrimSpace(detail.ItemId)} if detail.Snapshot != nil { row.Msisdn = detail.Snapshot.Get("MSISDN").String() row.Imsi = detail.Snapshot.Get("IMSI").String() row.SourceBatchNo = detail.Snapshot.Get("batchNo").String() } rows = append(rows, row) } if len(rows) == 0 { return nil, gerror.New("出库批次没有明细") } return rows, nil } func submitParamFromInfo(info *gjson.Json) (*model.TSimCardOutboundSubmitParam, error) { if info == nil { return nil, gerror.New("出库批次参数不存在") } param := &model.TSimCardOutboundSubmitParam{} if err := info.Scan(param); err != nil { return nil, err } if param.CustomerAgentId <= 0 || strings.TrimSpace(param.PackageCode) == "" || param.PackageBeginTime == nil { return nil, gerror.New("出库批次参数不完整") } return param, nil } const maxAsyncFailureReasonLength = 512 func asyncFailureReason(err error) string { if err == nil { return "异步出库失败" } reason := strings.TrimSpace(err.Error()) if reason == "" { return "异步出库失败" } if strings.Contains(reason, "Prepared statement contains too many placeholders") { return "新增正式卡失败:单次批量写入参数数量超过数据库限制" } if strings.Contains(reason, "Data too long for column 'error_reason'") { return "异步出库失败:失败原因超过数据库字段长度限制" } runes := []rune(reason) if len(runes) > maxAsyncFailureReasonLength { return string(runes[:maxAsyncFailureReasonLength]) } return reason }