package renewchange import ( "context" "strconv" "strings" "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/os/gtime" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do" batchrepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/batch" rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult" ) // Repository orchestrates table access + batch status writes for renew/change // service. It embeds the typed table repository and adds batch lifecycle // helpers that align with the Admin side batch_main/batch_detail contract. type Repository struct { tables *rc.Repository batchRepo *batchrepo.Repository results batchresult.Generator } // NewRepository builds a service-level repository backed by the typed table // repository (g.DB() default) and the shared batch repository. func NewRepository() *Repository { return &Repository{ tables: rc.New(), batchRepo: batchrepo.New(), results: batchresult.New(), } } // newRepositoryWithDeps allows tests to inject custom dependencies. func newRepositoryWithDeps(tables *rc.Repository, batchRepo *batchrepo.Repository, results batchresult.Generator) *Repository { return &Repository{tables: tables, batchRepo: batchRepo, results: results} } // Tables exposes the low-level repository for tests. func (r *Repository) Tables() *rc.Repository { return r.tables } // Batch exposes the batch repository. func (r *Repository) Batch() *batchrepo.Repository { return r.batchRepo } // Results exposes the result file generator. func (r *Repository) Results() batchresult.Generator { return r.results } // DB exposes the underlying gdb.DB handle. func (r *Repository) DB() gdb.DB { return r.tables.DB() } // LoadProcessingBatch loads a processing renew/change/prepare batch by // (batchNo, batchType). It returns an error if the batch is not in // processing state so the caller can log and skip safely. func (r *Repository) LoadProcessingBatch(ctx context.Context, batchNo string, batchType int) (*rc.BatchMainRow, error) { main, err := r.tables.LoadBatchMain(ctx, batchNo, batchType) if err != nil { return nil, err } if main == nil { return nil, gerror.Newf("batch %s (type %d) does not exist", batchNo, batchType) } if main.BatchAsyncStatus != batch.AsyncStatusProcessing { return nil, gerror.Newf("batch %s (type %d) is not in processing state: %d", batchNo, batchType, main.BatchAsyncStatus) } return main, nil } // LoadRunningDetails returns the running batch details ordered by row_no. func (r *Repository) LoadRunningDetails(ctx context.Context, batchNo string, batchType int) ([]*rc.BatchDetailRow, error) { return r.tables.LoadBatchDetails(ctx, batchNo, batchType, []int{consts.BatchDetailStatusRunning}) } // LoadAllDetails returns every detail regardless of status, used for the // confirm step (renew/change) to inspect previously committed results. func (r *Repository) LoadAllDetails(ctx context.Context, batchNo string, batchType int) ([]*rc.BatchDetailRow, error) { return r.tables.LoadBatchDetails(ctx, batchNo, batchType, nil) } type ConvertPrepareResult struct { MainAffected int64 DetailAffected int64 } // ConvertPrepareToRenewChange switches the prepare batch (type=4) to the // matching business type (101/102). The transaction must be the one used for // the detail update. func (r *Repository) ConvertPrepareToRenewChange(ctx context.Context, tx gdb.TX, batchNo string, targetType int) (*ConvertPrepareResult, error) { detailResult, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()). Where(dao.BatchDetail.Columns().BatchNo, batchNo). Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare). Data(do.BatchDetail{BatchType: targetType, UpdatedAt: gtime.Now()}). Update() if err != nil { return nil, gerror.Wrap(err, "convert prepare details to target type failed") } detailAffected, err := detailResult.RowsAffected() if err != nil { return nil, err } mainResult, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()). Where(dao.BatchMain.Columns().BatchNo, batchNo). Where(dao.BatchMain.Columns().BatchType, consts.BatchTypePrepare). Data(do.BatchMain{BatchType: targetType, UpdatedAt: gtime.Now()}). Update() if err != nil { return nil, gerror.Wrap(err, "convert prepare batch type failed") } mainAffected, err := mainResult.RowsAffected() if err != nil { return nil, err } if mainAffected == 0 { return nil, gerror.New("prepare batch main already converted or missing") } return &ConvertPrepareResult{ MainAffected: mainAffected, DetailAffected: detailAffected, }, nil } // MarkDetailFailed writes a single detail as failed with the given reason. func (r *Repository) MarkDetailFailed(ctx context.Context, tx gdb.TX, batchNo string, batchType, rowNo int, reason string, userID int64) error { return r.tables.UpdateBatchDetailSnapshot(ctx, tx, batchNo, batchType, rowNo, consts.BatchDetailStatusFailed, nil, reason, userID) } // MarkDetailSuccess writes a single detail as success with the given snapshot. func (r *Repository) MarkDetailSuccess(ctx context.Context, tx gdb.TX, batchNo string, batchType, rowNo int, snapshot *gjson.Json, userID int64) error { return r.tables.UpdateBatchDetailSnapshot(ctx, tx, batchNo, batchType, rowNo, consts.BatchDetailStatusSuccess, snapshot, "", userID) } func (r *Repository) MarkPrepareDetailSuccess(ctx context.Context, tx gdb.TX, batchNo string, rowNo int, itemID string, snapshot *gjson.Json, userID int64) error { data := map[string]any{ dao.BatchDetail.Columns().DetailStatus: consts.BatchDetailStatusSuccess, dao.BatchDetail.Columns().Snapshot: snapshot, dao.BatchDetail.Columns().UpdatedBy: userID, dao.BatchDetail.Columns().UpdatedAt: gtime.Now(), } if itemID = strings.TrimSpace(itemID); itemID != "" { data[dao.BatchDetail.Columns().ItemId] = itemID } _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()). Where(dao.BatchDetail.Columns().BatchNo, batchNo). Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare). Where(dao.BatchDetail.Columns().RowNo, rowNo). Data(data). Update() return gerror.Wrap(err, "mark prepare detail success failed") } // MarkAllDetailsFailed marks every unfinished detail as failed in a single update. func (r *Repository) MarkAllDetailsFailed(ctx context.Context, tx gdb.TX, batchNo string, batchType int, reason string, userID int64) error { _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()). Where(dao.BatchDetail.Columns().BatchNo, batchNo). Where(dao.BatchDetail.Columns().BatchType, batchType). WhereIn(dao.BatchDetail.Columns().DetailStatus, []int{consts.BatchDetailStatusPending, consts.BatchDetailStatusRunning}). Data(do.BatchDetail{ DetailStatus: consts.BatchDetailStatusFailed, ErrorReason: reason, UpdatedBy: userID, UpdatedAt: gtime.Now(), }). Update() return gerror.Wrap(err, "mark all running details failed") } // MarkAllDetailsSuccess marks every running detail as success. func (r *Repository) MarkAllDetailsSuccess(ctx context.Context, tx gdb.TX, batchNo string, batchType int, userID int64, extra map[string]any) error { data := map[string]any{ "detail_status": consts.BatchDetailStatusSuccess, "updated_by": userID, "updated_at": gtime.Now(), } for k, v := range extra { data[k] = v } _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()). Where(dao.BatchDetail.Columns().BatchNo, batchNo). Where(dao.BatchDetail.Columns().BatchType, batchType). Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning). Data(data). Update() return gerror.Wrap(err, "mark all running details success") } // UpdateBatchMainInfo merges new entries into batch_main.batch_info and // updates the lifecycle counters in a single statement. func (r *Repository) UpdateBatchMainInfo(ctx context.Context, tx gdb.TX, batchNo string, batchType int, data *rc.BatchMainRow) error { return r.tables.UpdateBatchMainStatus(ctx, tx, batchNo, batchType, data) } // FinalizeBatchMain updates only the lifecycle fields (status, counts, // fail_reason, end_time) while keeping batch_info untouched. func (r *Repository) FinalizeBatchMain(ctx context.Context, tx gdb.TX, batchNo string, batchType int, data *rc.BatchMainRow) error { _, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()). Where(dao.BatchMain.Columns().BatchNo, batchNo). Where(dao.BatchMain.Columns().BatchType, batchType). Data(do.BatchMain{ BatchAsyncStatus: data.BatchAsyncStatus, RunResult: data.RunResult, SuccessCount: data.SuccessCount, FailCount: data.FailCount, ValidCount: data.ValidCount, TotalCount: data.TotalCount, FailReason: data.FailReason, EndTime: data.EndTime, UpdatedBy: data.UpdatedBy, }). Update() return gerror.Wrap(err, "finalize batch main failed") } // ResolveResultSnapshot returns the per-row data needed to render the result // file. It always includes every detail (success and failure) for the batch. func (r *Repository) ResolveResultSnapshot(ctx context.Context, batchNo string, batchType int) ([]ResultRow, error) { details, err := r.tables.LoadBatchDetails(ctx, batchNo, batchType, nil) if err != nil { return nil, err } rows := make([]ResultRow, 0, len(details)) for _, d := range details { if d == nil { continue } msisdn, imsi := "", "" targetCode, packageCode, effectiveMode, orderNum := "", "", "", "" if d.Snapshot != nil { msisdn = strings.TrimSpace(d.Snapshot.Get("msisdn").String()) imsi = strings.TrimSpace(d.Snapshot.Get("imsi").String()) targetCode = strings.TrimSpace(d.Snapshot.Get("targetCode").String()) if targetCode == "" { targetCode = strings.TrimSpace(d.ItemId) } packageCode = strings.TrimSpace(d.Snapshot.Get("packageCode").String()) effectiveMode = strings.TrimSpace(d.Snapshot.Get("effectiveMode").String()) orderNum = strings.TrimSpace(d.Snapshot.Get("orderSn").String()) if orderNum == "" { orderNum = strings.TrimSpace(d.Snapshot.Get("orderNo").String()) } } rows = append(rows, ResultRow{ RowNo: d.RowNo, ICCID: strings.TrimSpace(d.ItemId), TargetCode: targetCode, PackageCode: packageCode, EffectiveMode: effectiveMode, MSISDN: msisdn, IMSI: imsi, Success: d.DetailStatus == consts.BatchDetailStatusSuccess, Reason: strings.TrimSpace(d.ErrorReason), OrderNum: orderNum, Snap: d.Snapshot, DetailID: d.Id, DetailCode: d.ErrorCode, }) } return rows, nil } // WriteResultFileSafe persists a result Excel and OSS/download records. It // never panics on a bad batch type; missing configuration is logged and the // business outcome is preserved. The caller must not abort the batch when // this returns an error. func (r *Repository) WriteResultFileSafe(ctx context.Context, batchNo string, batchType int, tenantID string, deptID, userID int64, rows []ResultRow) error { if r.results == nil { return nil } brRows := make([]batchresult.Row, 0, len(rows)) for _, row := range rows { brRows = append(brRows, batchresult.Row{ RowNo: row.RowNo, ObjectCode: row.TargetCode, TargetCode: row.TargetCode, PackageCode: row.PackageCode, EffectiveMode: row.EffectiveMode, ICCID: row.ICCID, MSISDN: row.MSISDN, IMSI: row.IMSI, Success: row.Success, Reason: row.Reason, OrderNum: row.OrderNum, }) } request := batchresult.Request{ BatchNo: batchNo, BatchType: batchType, TenantID: strings.TrimSpace(tenantID), Rows: brRows, } artifact, err := r.results.Create(ctx, request) if err != nil { g.Log().Warningf(ctx, "renew/change result file generation failed, batchNo:%s, batchType:%d, err:%+v", batchNo, batchType, err) return err } if err = r.persistArtifact(ctx, artifact, batchNo, batchType, tenantID, deptID, userID, len(rows)); err != nil { g.Log().Warningf(ctx, "renew/change result file bookkeeping failed, batchNo:%s, batchType:%d, err:%+v", batchNo, batchType, err) return err } return nil } func (r *Repository) persistArtifact(ctx context.Context, artifact batchresult.Artifact, batchNo string, batchType int, tenantID string, deptID, userID int64, total int) error { return r.tables.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { ossID, err := r.tables.InsertSysOss(ctx, tx, &rc.SysOssRow{ TenantId: tenantID, FileName: artifact.FileName, OriginalName: artifact.OriginalName, FileSuffix: artifact.FileSuffix, Path: artifact.Path, Url: artifact.URL, Md5: artifact.MD5, FileSize: artifact.Size, Service: "local", CreatedDept: deptID, CreatedBy: userID, UpdatedBy: userID, }) if err != nil { return err } successCount, failCount := 0, 0 main, mErr := r.tables.LoadBatchMain(ctx, batchNo, batchType) if mErr == nil && main != nil { successCount = main.SuccessCount failCount = main.FailCount } _, err = r.tables.InsertBatchDownload(ctx, tx, &rc.ResourceBatchDownloadRow{ BatchNo: batchNo, TaskName: batchresult.TaskName(batchType), ResourceType: consts.ResourceTypeExcel, ExportScene: batchType, TotalCount: total, SuccessCount: successCount, FailCount: failCount, DownloadStatus: consts.DownloadStatusGenerated, OssId: ossID, FileName: artifact.FileName, FileExt: artifact.FileSuffix, FileUrl: artifact.URL, FilePath: artifact.Path, FileSize: artifact.Size, FileMd5: artifact.MD5, DownloadCount: 0, DeptId: deptID, StartTime: gtime.Now(), FinishTime: gtime.Now(), TenantId: tenantID, CreatedDept: deptID, CreatedBy: userID, UpdatedBy: userID, }) return err }) } // LoadSystemContext returns tenant id and a default user id (CreatedBy) for // the given batch. Centralized so individual executors stay slim. func (r *Repository) LoadSystemContext(ctx context.Context, batchNo string, batchType int) (string, int64, int64, error) { main, err := r.tables.LoadBatchMain(ctx, batchNo, batchType) if err != nil { return "", 0, 0, err } if main == nil { return "", 0, 0, gerror.Newf("batch %s (type %d) does not exist", batchNo, batchType) } return main.TenantId, main.CreatedBy, main.CreatedDept, nil } // ResultRow is the per-row information needed by the result file. type ResultRow struct { RowNo int ICCID string TargetCode string PackageCode string EffectiveMode string MSISDN string IMSI string Success bool Reason string OrderNum string Upstream int Snap *gjson.Json DetailID int64 DetailCode string } func tenantIDtoString(tenantID int64) string { return strconv.FormatInt(tenantID, 10) }