chore: 初始开源发布

iotlcm-admin-svr:管理后台服务。

- Go module:iotlcm → git.5iot.com/iotlcm-public/iotlcm-admin-svr(729 文件)
- 凭据与地址脱敏:JWT signingKey(含代码里的 MustGet 默认值)、
  MySQL DSN、Redis 口令、告警邮件发件人邮箱全部改为占位;
  私网 IP → 文档地址段;库名 xiujie_admin → iotlcm
- 真实手机号/邮箱 → 合法 11 位编造号 / example.com
- 移除内部资产:CI 触发器、过时 DDL(sql/db_iotlcm.sql)、
  私有依赖 go get 行、明文 registry 登录口令
- 启动 banner 统一 IOTLCM 方块字 + 社区惯例署名
- Dockerfile 改用公共基础镜像
- 拼错的 RADEME.md → README.md,README 重写
- 需要真实数据库的用例改为可用 IOTLCM_TEST_WITH_DB=1 显式启用
- 补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes

数据库 schema 不随本仓发布(生产 DDL 已过时),README 已说明。
This commit is contained in:
MiFaZhan
2026-10-23 14:46:05 +08:00
commit 59eb34976a
1132 changed files with 138197 additions and 0 deletions
@@ -0,0 +1,231 @@
package renew
import (
"context"
"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/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"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"
)
type AsyncConfirmInput struct {
BatchNo string
TargetBatchType int
DurationYears int
DurationMonths int
GiftDurationMonths int
RenewDate string
PackageBeginTime string
PackageEndTime string
OfflineContract bool
BuyerNote string
AdminNote string
Remark string
PriceSnapshot *model.TSimCardRenewPriceSnapshot
PaymentSnapshot *model.TSimCardRenewPaymentSnapshot
SourceBatchNo string
Operator batchimport.OperatorContext
}
type AsyncConfirmResult struct {
BatchNo string
BatchType int
BatchAsyncStatus int
}
// ConvertPrepareToAsync 以 CAS 方式把 batch_type=4 + batch_async_status=Success + run_result=Success 的预处理批次
// 转换为 batch_type=101 的待处理续费批次:重置主表正式执行相关字段并把明细的 BatchType 切换到 101、DetailStatus 重置为 Pending。
func ConvertPrepareToAsync(ctx context.Context, tx gdb.TX, in *AsyncConfirmInput) (*AsyncConfirmResult, error) {
if in == nil {
return nil, gerror.New("续费确认参数不能为空")
}
batchNo := strings.TrimSpace(in.BatchNo)
if batchNo == "" {
return nil, gerror.New("续费批次号不能为空")
}
targetBatchType := in.TargetBatchType
if targetBatchType == 0 {
targetBatchType = consts.BatchTypeRenew
}
columns := dao.BatchMain.Columns()
main := &entity.BatchMain{}
err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(columns.BatchNo, batchNo).
Where(columns.TenantId, contexts.GetTenantId(ctx)).
Where(columns.CreatedDept, contexts.GetDeptId(ctx)).
LockUpdate().
Scan(main)
if err != nil {
return nil, gerror.Wrap(err, "查询续费预处理批次失败")
}
if main.Id == 0 {
return nil, gerror.New("续费预处理批次不存在")
}
if main.BatchType != consts.BatchTypePrepare {
return nil, gerror.New("续费批次不是预处理类型")
}
if main.BatchAsyncStatus != consts.BatchAsyncStatusSuccess {
return nil, gerror.New("续费预处理批次尚未完成")
}
if main.RunResult != consts.BatchRunResultSuccess {
return nil, gerror.New("续费预处理批次未成功")
}
if main.BatchInfo == nil {
return nil, gerror.New("续费预处理批次缺少快照")
}
if main.BatchInfo.Get("targetBatchType").Int() != targetBatchType {
return nil, gerror.New("续费预处理批次类型不匹配")
}
now := gtime.Now()
info := mergeConfirmBatchInfo(main.BatchInfo, in)
data := do.BatchMain{
BatchType: targetBatchType,
BatchAsyncStatus: consts.BatchAsyncStatusPending,
RunResult: consts.BatchRunResultPending,
StartTime: nil,
EndTime: nil,
FailReason: "",
SuccessCount: 0,
FailCount: 0,
BatchInfo: gjson.New(info),
UpdatedBy: in.Operator.UserId,
UpdatedAt: now,
}
res, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(columns.Id, main.Id).
Where(columns.BatchType, consts.BatchTypePrepare).
Where(columns.BatchAsyncStatus, consts.BatchAsyncStatusSuccess).
Where(columns.RunResult, consts.BatchRunResultSuccess).
Data(data).
Update()
if err != nil {
return nil, gerror.Wrap(err, "更新续费批次状态失败")
}
affected, err := res.RowsAffected()
if err != nil {
return nil, gerror.Wrap(err, "读取续费批次更新结果失败")
}
if affected != 1 {
return nil, gerror.New("续费批次状态已变化,请重试")
}
detailColumns := dao.BatchDetail.Columns()
if _, err = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(detailColumns.BatchNo, batchNo).
Where(detailColumns.BatchType, consts.BatchTypePrepare).
Data(do.BatchDetail{
BatchType: targetBatchType,
DetailStatus: consts.BatchDetailStatusPending,
ErrorCode: "",
ErrorReason: "",
UpdatedBy: in.Operator.UserId,
UpdatedAt: now,
}).
Update(); err != nil {
return nil, gerror.Wrap(err, "更新续费批次明细类型失败")
}
return &AsyncConfirmResult{
BatchNo: batchNo,
BatchType: targetBatchType,
BatchAsyncStatus: consts.BatchAsyncStatusPending,
}, nil
}
func mergeConfirmBatchInfo(base *gjson.Json, in *AsyncConfirmInput) g.Map {
data := g.Map{}
if base != nil {
for key, value := range base.Map() {
data[key] = value
}
}
if in == nil {
return data
}
durationValue := in.DurationYears
if durationValue <= 0 {
durationValue = renewDurationYearsFromLegacy(in.DurationMonths, 0, in.PriceSnapshot)
}
if durationValue > 0 {
data["durationValue"] = durationValue
data["durationYears"] = durationValue
actualDurationMonths := renewActualDurationMonths(in.PriceSnapshot, durationValue)
data["durationMonths"] = actualDurationMonths
data["actualDurationMonths"] = actualDurationMonths
}
if in.GiftDurationMonths >= 0 {
data["giftDurationMonths"] = in.GiftDurationMonths
}
if strings.TrimSpace(in.RenewDate) != "" {
data["renewDate"] = strings.TrimSpace(in.RenewDate)
}
if strings.TrimSpace(in.PackageBeginTime) != "" {
data["packageBeginTime"] = strings.TrimSpace(in.PackageBeginTime)
}
if strings.TrimSpace(in.PackageEndTime) != "" {
data["packageEndTime"] = strings.TrimSpace(in.PackageEndTime)
}
data["offlineContractCompleted"] = in.OfflineContract
if note := strings.TrimSpace(in.BuyerNote); note != "" {
data["buyerNote"] = note
}
if note := strings.TrimSpace(in.AdminNote); note != "" {
data["adminNote"] = note
}
if note := strings.TrimSpace(in.Remark); note != "" {
data["remark"] = note
}
if in.PriceSnapshot != nil {
data["priceSnapshot"] = in.PriceSnapshot
data["packageCode"] = in.PriceSnapshot.PackageCode
data["packageName"] = in.PriceSnapshot.PackageName
data["renewUnitPrice"] = in.PriceSnapshot.RenewUnitPrice
data["billingMethod"] = in.PriceSnapshot.BillingMethod
data["renewDurationUnit"] = in.PriceSnapshot.RenewDurationUnit
data["renewDurationUnitText"] = in.PriceSnapshot.RenewDurationUnitText
data["priceSource"] = in.PriceSnapshot.PriceSource
data["priceOwnerDeptId"] = in.PriceSnapshot.PriceOwnerDeptId
data["agentDefaultPriceId"] = in.PriceSnapshot.AgentDefaultPriceId
data["targetPriceId"] = in.PriceSnapshot.TargetPriceId
data["renewableDurationOptions"] = in.PriceSnapshot.RenewableDurationOptions
data["renewablePackageCodes"] = in.PriceSnapshot.RenewablePackageCodes
data["renewablePackages"] = in.PriceSnapshot.RenewablePackages
if durationValue > 0 {
data["actualDurationMonths"] = renewActualDurationMonths(in.PriceSnapshot, durationValue)
}
}
if in.PaymentSnapshot != nil {
data["paymentMode"] = strings.TrimSpace(in.PaymentSnapshot.PaymentMode)
data["payType"] = in.PaymentSnapshot.PayType
data["payStatus"] = in.PaymentSnapshot.PayStatus
data["payAmount"] = in.PaymentSnapshot.PayAmount
data["balanceAmount"] = in.PaymentSnapshot.BalanceAmount
data["offlinePaidAmount"] = in.PaymentSnapshot.OfflinePaidAmount
data["balanceBefore"] = in.PaymentSnapshot.BalanceBefore
data["balanceAfter"] = in.PaymentSnapshot.BalanceAfter
data["balanceDetailSerialNo"] = strings.TrimSpace(in.PaymentSnapshot.BalanceDetailSerialNo)
data["payerDeptId"] = in.PaymentSnapshot.PayerDeptId
data["payerUserId"] = in.PaymentSnapshot.PayerUserId
data["isTopDept"] = in.PaymentSnapshot.IsTopDept
if in.PaymentSnapshot.PaymentTime != nil {
data["paymentTime"] = in.PaymentSnapshot.PaymentTime.Format("Y-m-d H:i:s")
}
data["paymentSnapshot"] = in.PaymentSnapshot
}
if strings.TrimSpace(in.SourceBatchNo) != "" {
data["sourceBatchNo"] = strings.TrimSpace(in.SourceBatchNo)
}
return data
}
@@ -0,0 +1,320 @@
package renew
import (
"context"
"strings"
"time"
"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/logic/iot/cardpackage"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"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"
)
func (p *Processor) Complete(ctx context.Context, tx gdb.TX, in *CompleteInput) (*CompleteOutput, error) {
if in == nil || in.SubmitParam == nil {
return nil, gerror.New("续费参数不能为空")
}
sourceBatchNo := strings.TrimSpace(in.SubmitParam.SourceBatchNo)
if in.BatchNo == "" {
return nil, gerror.New("续费批次号不能为空")
}
if len(in.Rows) == 0 {
return nil, gerror.New("续费卡不能为空")
}
iccids := rowsToIccids(in.Rows)
paymentSnapshot, err := executeRenewPayment(ctx, tx, paymentExecutionInput{
BatchNo: in.BatchNo,
PriceSnapshot: in.PriceSnapshot,
Quantity: len(iccids),
SubmitParam: in.SubmitParam,
Operator: in.Operator,
})
if err != nil {
return nil, err
}
in.SubmitParam.PaymentSnapshot = paymentSnapshot
in.SubmitParam.PaymentMode = paymentSnapshot.PaymentMode
in.SubmitParam.Amount = formatRenewAmount(paymentSnapshot.PayAmount)
packageSvc := cardpackage.NewService()
durationValue := renewDisplayDurationValue(in.SubmitParam)
durationUnit := renewDurationUnitFromSnapshot(in.PriceSnapshot)
actualDurationMonths := renewActualDurationMonths(in.PriceSnapshot, durationValue)
if actualDurationMonths <= 0 {
return nil, gerror.New("续费时长必须大于0")
}
packageBeginTime := strings.TrimSpace(in.SubmitParam.PackageBeginTime)
var packageBeginAt *gtime.Time
if strings.TrimSpace(packageBeginTime) != "" {
packageBeginAt, err = gtime.StrToTime(packageBeginTime)
if err != nil {
return nil, gerror.Wrap(err, "续费开始日期格式不正确")
}
packageBeginAt = normalizeRenewDayEndTime(packageBeginAt)
}
packageEndTime := strings.TrimSpace(in.SubmitParam.PackageEndTime)
var packageEndAt *gtime.Time
if strings.TrimSpace(packageEndTime) != "" {
packageEndAt, err = gtime.StrToTime(packageEndTime)
if err != nil {
return nil, gerror.Wrap(err, "续费结束日期格式不正确")
}
packageEndAt = normalizeRenewDayEndTime(packageEndAt)
}
renew, err := packageSvc.RenewCurrentPackage(ctx, tx, &cardpackage.RenewCurrentPackageInput{
BatchNo: in.BatchNo,
SourceBatchNo: sourceBatchNo,
PackageCode: strings.TrimSpace(in.SubmitParam.PackageCode),
Iccids: iccids,
DurationMonths: actualDurationMonths,
GiftDurationMonths: in.SubmitParam.GiftDurationMonths,
DurationDisplayValue: durationValue,
DurationUnit: durationUnit,
PackageBeginTime: packageBeginAt,
PackageEndTime: packageEndAt,
Amount: in.SubmitParam.Amount,
PayType: paymentSnapshot.PayType,
PayStatus: paymentSnapshot.PayStatus,
BalanceAmount: paymentSnapshot.BalanceAmount,
OfflinePaidAmount: paymentSnapshot.OfflinePaidAmount,
PayTime: paymentSnapshot.PaymentTime,
OfflineContractCompleted: in.SubmitParam.OfflineContractCompleted,
BuyerNote: in.SubmitParam.BuyerNote,
AdminNote: in.SubmitParam.AdminNote,
OrderRemark: "卡续费业务订单载体",
BindRemark: in.BatchNo,
Operator: in.Operator,
})
if err != nil {
return nil, err
}
detailUpdates := make(map[string]*gjsonJsonHolder, len(iccids))
for _, row := range in.Rows {
card := renew.CardsByIccid[row.Iccid]
latestBind := renew.LatestBindsByIccid[row.Iccid]
newBind := renew.NewBindsByIccid[row.Iccid]
detailUpdates[row.Iccid] = &gjsonJsonHolder{Data: buildDetailSnapshot(row, card, latestBind, newBind, in, renew)}
}
if err = updateBatchDetailSnapshots(ctx, tx, in.BatchNo, detailUpdates, in.Operator.UserId); err != nil {
return nil, err
}
if err = convertBatchDetailsAfterComplete(ctx, tx, in.BatchNo, in.Operator.UserId); err != nil {
return nil, err
}
if err = updateBatchMainAfterComplete(ctx, tx, in, renew); err != nil {
return nil, err
}
renewUnitPrice := renew.UnitPrice
if in.PriceSnapshot != nil {
renewUnitPrice = in.PriceSnapshot.RenewUnitPrice
}
return &CompleteOutput{
BatchNo: in.BatchNo,
BatchType: consts.BatchTypeRenew,
SourceBatchNo: sourceBatchNo,
Quantity: len(iccids),
PackageCode: renew.PackageCode,
PackageName: renew.Package.PackageName,
OrderSn: renew.OrderSn,
Amount: renew.Amount,
RenewUnitPrice: renewUnitPrice,
RenewDate: in.RenewDate,
RenewTime: renew.RenewTime,
}, nil
}
func normalizeRenewDayEndTime(t *gtime.Time) *gtime.Time {
if t == nil || t.IsZero() {
return t
}
tt := t.Time
return gtime.NewFromTime(time.Date(tt.Year(), tt.Month(), tt.Day(), 23, 59, 59, 0, tt.Location()))
}
type gjsonJsonHolder struct {
Data any
}
func rowsToIccids(rows []*Row) []string {
iccids := make([]string, 0, len(rows))
for _, row := range rows {
if row == nil {
continue
}
iccid := strings.TrimSpace(row.Iccid)
if iccid != "" {
iccids = append(iccids, iccid)
}
}
return uniqueStrings(iccids)
}
func updateBatchDetailSnapshots(ctx context.Context, tx gdb.TX, batchNo string, snapshots map[string]*gjsonJsonHolder, userId int64) error {
for iccid, snapshot := range snapshots {
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().ItemId, iccid).
Data(do.BatchDetail{
Snapshot: gjson.New(snapshot.Data),
UpdatedBy: userId,
UpdatedAt: gtime.Now(),
}).
Update()
if err != nil {
return gerror.Wrap(err, "更新续费批次明细快照失败")
}
}
return nil
}
func updateBatchMainAfterComplete(ctx context.Context, tx gdb.TX, in *CompleteInput, renew *cardpackage.RenewCurrentPackageOutput) error {
durationValue := renewDisplayDurationValue(in.SubmitParam)
actualDurationMonths := renewActualDurationMonths(in.PriceSnapshot, durationValue)
info := g.Map{
"orderSn": renew.OrderSn,
"packageCode": renew.PackageCode,
"packageName": renew.Package.PackageName,
"packageType": renew.Package.PackageType,
"durationYears": in.SubmitParam.DurationYears,
"durationMonths": actualDurationMonths,
"durationValue": durationValue,
"actualDurationMonths": actualDurationMonths,
"giftDurationMonths": in.SubmitParam.GiftDurationMonths,
"amount": renew.Amount,
"quantity": renew.Quantity,
"renewDate": in.RenewDate,
"packageBeginTime": in.SubmitParam.PackageBeginTime,
"packageEndTime": in.SubmitParam.PackageEndTime,
"offlineContractCompleted": in.SubmitParam.OfflineContractCompleted,
"renewTime": renew.RenewTime,
}
if sourceBatchNo := strings.TrimSpace(in.SubmitParam.SourceBatchNo); sourceBatchNo != "" {
info["sourceBatchNo"] = sourceBatchNo
}
if buyerNote := strings.TrimSpace(in.SubmitParam.BuyerNote); buyerNote != "" {
info["buyerNote"] = buyerNote
}
if adminNote := strings.TrimSpace(in.SubmitParam.AdminNote); adminNote != "" {
info["adminNote"] = adminNote
}
if remark := strings.TrimSpace(in.SubmitParam.Remark); remark != "" {
info["remark"] = remark
}
if in.PriceSnapshot != nil {
info["customerAgentId"] = in.PriceSnapshot.CustomerAgentId
info["billingMethod"] = in.PriceSnapshot.BillingMethod
info["renewDurationUnit"] = in.PriceSnapshot.RenewDurationUnit
info["renewDurationUnitText"] = in.PriceSnapshot.RenewDurationUnitText
info["priceOwnerDeptId"] = in.PriceSnapshot.PriceOwnerDeptId
info["priceSource"] = in.PriceSnapshot.PriceSource
info["agentDefaultPriceId"] = in.PriceSnapshot.AgentDefaultPriceId
info["targetPriceId"] = in.PriceSnapshot.TargetPriceId
info["renewUnitPrice"] = in.PriceSnapshot.RenewUnitPrice
info["renewableDurationOptions"] = in.PriceSnapshot.RenewableDurationOptions
info["renewablePackageCodes"] = in.PriceSnapshot.RenewablePackageCodes
info["renewablePackages"] = in.PriceSnapshot.RenewablePackages
}
if in.SubmitParam.PaymentSnapshot != nil {
appendRenewPaymentSnapshot(info, in.SubmitParam.PaymentSnapshot)
}
_, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, in.BatchNo).
Data(do.BatchMain{
BatchType: consts.BatchTypeRenew,
OrderSn: renew.OrderSn,
TargetDeptId: renew.Customer.DeptId,
BatchInfo: gjson.New(info),
UpdatedBy: in.Operator.UserId,
UpdatedAt: renew.RenewTime,
}).
Update()
if err != nil {
return gerror.Wrap(err, "更新续费批次失败")
}
return nil
}
func convertBatchDetailsAfterComplete(ctx context.Context, tx gdb.TX, batchNo string, userId int64) error {
_, 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: consts.BatchTypeRenew,
UpdatedBy: userId,
UpdatedAt: gtime.Now(),
}).
Update()
if err != nil {
return gerror.Wrap(err, "更新续费批次明细类型失败")
}
return nil
}
func buildDetailSnapshot(row *Row, card *entity.TSimCardInfo, latestBind, newBind *entity.TSimCardBindPkg, in *CompleteInput, renew *cardpackage.RenewCurrentPackageOutput) g.Map {
durationValue := renewDisplayDurationValue(in.SubmitParam)
actualDurationMonths := renewActualDurationMonths(in.PriceSnapshot, durationValue)
data := g.Map{
"ICCID": card.Iccid,
"MSISDN": card.Msisdn,
"IMSI": card.Imsi,
"renewBatchNo": in.BatchNo,
"packageCode": renew.PackageCode,
"packageName": renew.Package.PackageName,
"oldSubscriptionNo": latestBind.SubscriptionNo,
"newSubscriptionNo": newBind.SubscriptionNo,
"orderSn": renew.OrderSn,
"oldExpectedExpireTime": formatTime(latestBind.ExpectedExpireTime),
"renewEffectiveTime": formatTime(newBind.EffectiveTime),
"renewExpectedExpireTime": formatTime(newBind.ExpectedExpireTime),
"cardFlowPkgEndAt": formatTime(newBind.ExpectedExpireTime),
"durationYears": in.SubmitParam.DurationYears,
"durationMonths": actualDurationMonths,
"durationValue": durationValue,
"actualDurationMonths": actualDurationMonths,
"giftDurationMonths": in.SubmitParam.GiftDurationMonths,
"renewDate": in.RenewDate,
"packageBeginTime": in.SubmitParam.PackageBeginTime,
"packageEndTime": in.SubmitParam.PackageEndTime,
"amount": renew.Amount,
"rowNo": row.RowNo,
}
if sourceBatchNo := strings.TrimSpace(in.SubmitParam.SourceBatchNo); sourceBatchNo != "" {
data["sourceBatchNo"] = sourceBatchNo
}
if in.PriceSnapshot != nil {
data["renewUnitPrice"] = in.PriceSnapshot.RenewUnitPrice
data["billingMethod"] = in.PriceSnapshot.BillingMethod
data["renewDurationUnit"] = in.PriceSnapshot.RenewDurationUnit
data["renewDurationUnitText"] = in.PriceSnapshot.RenewDurationUnitText
data["priceOwnerDeptId"] = in.PriceSnapshot.PriceOwnerDeptId
data["priceSource"] = in.PriceSnapshot.PriceSource
}
if snapshot := in.SubmitParam.PaymentSnapshot; snapshot != nil {
data["paymentMode"] = snapshot.PaymentMode
data["payType"] = snapshot.PayType
data["payAmount"] = snapshot.PayAmount
data["balanceAmount"] = snapshot.BalanceAmount
data["balanceBefore"] = snapshot.BalanceBefore
data["balanceAfter"] = snapshot.BalanceAfter
data["balanceDetailSerialNo"] = snapshot.BalanceDetailSerialNo
}
return data
}
func formatTime(t *gtime.Time) string {
if t == nil {
return ""
}
return t.Format("Y-m-d H:i:s")
}
@@ -0,0 +1,101 @@
package renew
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
)
type ExcelProcessor struct {
*Processor
options *ExcelSubmitOptions
}
func NewExcelProcessor() *ExcelProcessor {
return &ExcelProcessor{
Processor: NewProcessor(),
}
}
// PrepareExcelProcessor parses the customer-facing Excel format but persists
// an internal prepare batch. The worker performs the database validation before
// the user can confirm the formal renew batch.
type PrepareExcelProcessor struct {
parser *ExcelProcessor
prepare *PrepareProcessor
}
func NewPrepareExcelProcessor() *PrepareExcelProcessor {
return &PrepareExcelProcessor{
parser: NewExcelProcessor(),
prepare: NewPrepareProcessor(),
}
}
func (p *ExcelProcessor) Config() batchimport.ProcessorConfig {
cfg := p.Processor.Config()
cfg.TemplateHeaders = ExcelTemplateHeaders()
cfg.RequiredHeaders = []string{"ICCID"}
return cfg
}
func (p *ExcelProcessor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
rows, options, err := ParseExcelFileRows(ctx, filePath)
if err != nil {
return nil, err
}
p.options = options
out := make([]batchimport.Row, 0, len(rows))
for _, row := range rows {
out = append(out, row)
}
return out, nil
}
func (p *ExcelProcessor) Execute(ctx context.Context, tx gdb.TX, req *batchimport.SubmitRequest, rows []batchimport.Row) error {
return p.Processor.Execute(ctx, tx, req, rows)
}
func (p *ExcelProcessor) BuildBatchInfo(ctx context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
return p.Processor.BuildBatchInfo(ctx, req, result, importOss, resultOss, downloadId)
}
func (p *ExcelProcessor) ExcelSubmitOptions() *ExcelSubmitOptions {
return p.options
}
func (p *PrepareExcelProcessor) Config() batchimport.ProcessorConfig {
cfg := p.prepare.Config()
cfg.TemplateHeaders = ExcelTemplateHeaders()
cfg.RequiredHeaders = []string{"ICCID"}
return cfg
}
func (p *PrepareExcelProcessor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
return p.parser.ParseRows(ctx, filePath)
}
func (p *PrepareExcelProcessor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
// Keep submit non-blocking; the worker owns prepare validation.
return nil
}
func (p *PrepareExcelProcessor) ExcelSubmitOptions() *ExcelSubmitOptions {
return p.parser.ExcelSubmitOptions()
}
func (p *PrepareExcelProcessor) BuildBatchInfo(
ctx context.Context,
req batchimport.SubmitRequest,
result *batchimport.Result,
importOss *model.SysOssSaveContentModel,
resultOss *model.SysOssSaveContentModel,
downloadId int64,
) *gjson.Json {
return p.prepare.BuildBatchInfo(ctx, req, result, importOss, resultOss, downloadId)
}
@@ -0,0 +1,189 @@
package renew
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"github.com/gogf/gf/v2/errors/gerror"
)
type renewIdentityResolver struct {
byValue map[string]map[int64]*entity.TSimCardInfo
}
var renewIdentityResolverLoader = loadRenewIdentityResolver
func newRenewIdentityResolverFromCards(cards []*entity.TSimCardInfo) *renewIdentityResolver {
resolver := &renewIdentityResolver{
byValue: make(map[string]map[int64]*entity.TSimCardInfo),
}
for _, card := range cards {
resolver.addCard(card)
}
return resolver
}
func loadRenewIdentityResolver(ctx context.Context, rows []*Row) (*renewIdentityResolver, error) {
keys := collectRenewIdentityKeys(rows)
resolver := newRenewIdentityResolverFromCards(nil)
if len(keys) == 0 {
return resolver, nil
}
for _, column := range []string{
dao.TSimCardInfo.Columns().Iccid,
dao.TSimCardInfo.Columns().Msisdn,
dao.TSimCardInfo.Columns().Imsi,
} {
cards, err := queryRenewIdentityCardsByColumn(ctx, column, keys)
if err != nil {
return nil, err
}
for _, card := range cards {
resolver.addCard(card)
}
}
return resolver, nil
}
func queryRenewIdentityCardsByColumn(ctx context.Context, column string, values []string) ([]*entity.TSimCardInfo, error) {
if len(values) == 0 {
return nil, nil
}
var cards []*entity.TSimCardInfo
if err := service.IotTSimCardInfo().Model(ctx).
Fields(
dao.TSimCardInfo.Columns().Id,
dao.TSimCardInfo.Columns().Iccid,
dao.TSimCardInfo.Columns().Msisdn,
dao.TSimCardInfo.Columns().Imsi,
).
WhereIn(column, values).
Scan(&cards); err != nil {
return nil, gerror.Wrap(err, "查询续费卡信息失败")
}
return cards, nil
}
func collectRenewIdentityKeys(rows []*Row) []string {
keys := make([]string, 0, len(rows)*3)
for _, row := range rows {
if row == nil {
continue
}
keys = append(keys,
strings.TrimSpace(row.Iccid),
strings.TrimSpace(row.Msisdn),
strings.TrimSpace(row.Imsi),
)
}
return uniqueStrings(keys)
}
func (r *renewIdentityResolver) addCard(card *entity.TSimCardInfo) {
if r == nil || card == nil || card.Id <= 0 {
return
}
r.addValue(strings.TrimSpace(card.Iccid), card)
r.addValue(strings.TrimSpace(card.Msisdn), card)
r.addValue(strings.TrimSpace(card.Imsi), card)
}
func (r *renewIdentityResolver) addValue(value string, card *entity.TSimCardInfo) {
if r == nil || card == nil || card.Id <= 0 || value == "" {
return
}
if r.byValue == nil {
r.byValue = make(map[string]map[int64]*entity.TSimCardInfo)
}
cards := r.byValue[value]
if cards == nil {
cards = make(map[int64]*entity.TSimCardInfo)
r.byValue[value] = cards
}
cards[card.Id] = card
}
func (r *renewIdentityResolver) resolveRow(row *Row) {
if r == nil || row == nil {
return
}
identities := uniqueStrings([]string{
strings.TrimSpace(row.Iccid),
strings.TrimSpace(row.Msisdn),
strings.TrimSpace(row.Imsi),
})
if len(identities) == 0 {
row.AddReason("ICCID/MSISDN/IMSI不能为空")
return
}
card, err := r.resolve(identities)
if err != nil {
row.AddReason(err.Error())
return
}
if card == nil {
row.AddReason("卡号不存在或无权限访问")
return
}
for _, value := range identities {
if !cardIdentityMatches(card, value) {
row.AddReason("ICCID/MSISDN/IMSI与卡信息不一致")
return
}
}
row.Iccid = strings.TrimSpace(card.Iccid)
row.Msisdn = strings.TrimSpace(card.Msisdn)
row.Imsi = strings.TrimSpace(card.Imsi)
}
func (r *renewIdentityResolver) resolve(values []string) (*entity.TSimCardInfo, error) {
if r == nil || len(values) == 0 {
return nil, nil
}
matches := make(map[int64]*entity.TSimCardInfo)
for _, value := range uniqueStrings(values) {
for id, card := range r.byValue[strings.TrimSpace(value)] {
matches[id] = card
}
}
switch len(matches) {
case 0:
return nil, nil
case 1:
for _, card := range matches {
return card, nil
}
}
return nil, gerror.New("卡号对应多条卡记录,请输入 ICCID")
}
func cardIdentityMatches(card *entity.TSimCardInfo, value string) bool {
if card == nil {
return false
}
value = strings.TrimSpace(value)
if value == "" {
return false
}
return value == strings.TrimSpace(card.Iccid) ||
value == strings.TrimSpace(card.Msisdn) ||
value == strings.TrimSpace(card.Imsi)
}
func normalizeRenewIdentityRows(ctx context.Context, rows []*Row) error {
resolver, err := renewIdentityResolverLoader(ctx, rows)
if err != nil {
return err
}
for _, row := range rows {
resolver.resolveRow(row)
}
return nil
}
@@ -0,0 +1,59 @@
package renew
import (
"context"
"errors"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
"github.com/gogf/gf/v2/errors/gerror"
)
func ParseInputRows(ctx context.Context, input string) ([]batchimport.Row, error) {
lines, err := text.ParseWhitespaceFields(ctx, input)
if err != nil {
if errors.Is(err, text.ErrEmptyInput) {
return nil, gerror.New("批量输入续费数据为空")
}
return nil, err
}
renewRows := make([]*Row, 0, len(lines))
for _, line := range lines {
renewRows = append(renewRows, parseRenewInputLine(line))
}
rows := make([]batchimport.Row, 0, len(renewRows))
for _, row := range renewRows {
rows = append(rows, row)
}
return rows, nil
}
func parseRenewInputLine(line text.FieldLine) *Row {
row := &Row{RowNo: line.RowNo}
switch len(line.Fields) {
case 1:
row.InputCode = strings.TrimSpace(line.Fields[0])
row.Iccid = row.InputCode
case 3, 4:
row.Iccid = strings.TrimSpace(line.Fields[0])
row.Msisdn = strings.TrimSpace(line.Fields[1])
row.Imsi = strings.TrimSpace(line.Fields[2])
row.InputCode = firstNonEmpty(row.Iccid, row.Msisdn, row.Imsi)
default:
row.AddReason("格式错误,应为 ICCID/MSISDN/IMSI 任一号码,或 ICCID MSISDN IMSI")
}
return row
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
@@ -0,0 +1,194 @@
package renew
import (
"context"
"errors"
"strconv"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
)
type ExcelSubmitOptions struct {
DurationYears int
PaymentMode string
}
func ExcelTemplateHeaders() []string {
return []string{
"ICCID",
"MSISDN",
"IMSI",
"续费年限",
"是否走线下",
}
}
func (o *ExcelSubmitOptions) ToBatchSubmitParam() *model.TSimCardRenewBatchSubmitParam {
if o == nil {
return &model.TSimCardRenewBatchSubmitParam{}
}
return &model.TSimCardRenewBatchSubmitParam{
DurationYears: o.DurationYears,
DurationMonths: o.DurationYears * 12,
PaymentMode: strings.TrimSpace(o.PaymentMode),
OfflineContractCompleted: true,
}
}
func (o *ExcelSubmitOptions) ToBatchConfirmParam(batchNo string) *model.TSimCardRenewBatchConfirmParam {
if o == nil {
return &model.TSimCardRenewBatchConfirmParam{BatchNo: strings.TrimSpace(batchNo), OfflineContractCompleted: true}
}
return &model.TSimCardRenewBatchConfirmParam{
BatchNo: strings.TrimSpace(batchNo),
DurationYears: o.DurationYears,
DurationMonths: o.DurationYears * 12,
PaymentMode: strings.TrimSpace(o.PaymentMode),
OfflineContractCompleted: true,
}
}
func ParseExcelFileRows(ctx context.Context, filePath string) ([]*Row, *ExcelSubmitOptions, error) {
parsedRows, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{
RequiredHeaders: []string{"ICCID"},
SingleSheet: true,
})
if err != nil {
return nil, nil, err
}
rows := make([]*Row, 0, len(parsedRows))
var options *ExcelSubmitOptions
inconsistent := false
for _, parsed := range parsedRows {
row := &Row{
RowNo: parsed.RowNo,
Iccid: excel.Field(parsed, "ICCID"),
Msisdn: excel.Field(parsed, "MSISDN"),
Imsi: excel.Field(parsed, "IMSI"),
InputCode: firstNonEmpty(
excel.Field(parsed, "ICCID"),
excel.Field(parsed, "MSISDN"),
excel.Field(parsed, "IMSI"),
),
}
rowOptions, reasons := parseExcelSubmitOptions(parsed)
if len(reasons) > 0 {
row.AddReason(strings.Join(reasons, "; "))
rows = append(rows, row)
continue
}
if options == nil {
options = rowOptions
} else if !sameExcelSubmitOptions(options, rowOptions) {
row.AddReason("同一批续费年限和是否走线下必须一致")
inconsistent = true
}
rows = append(rows, row)
}
if inconsistent {
markRowsWithoutReason(rows, "同一批续费年限和是否走线下必须一致")
}
return rows, options, nil
}
func parseExcelSubmitOptions(row excel.ParsedRow) (*ExcelSubmitOptions, []string) {
var reasons []string
durationYears, err := parseExcelDurationYears(row)
if err != nil {
reasons = append(reasons, err.Error())
}
paymentMode, err := parseExcelPaymentMode(row)
if err != nil {
reasons = append(reasons, err.Error())
}
if len(reasons) > 0 {
return nil, reasons
}
return &ExcelSubmitOptions{
DurationYears: durationYears,
PaymentMode: paymentMode,
}, nil
}
func parseExcelDurationYears(row excel.ParsedRow) (int, error) {
if value := strings.TrimSpace(excelField(row, "续费年限(年)")); value != "" {
years, err := parsePositiveInt(value)
if err != nil {
return 0, errors.New("续费年限必须为正整数")
}
return years, nil
}
if value := strings.TrimSpace(excelField(row, "续费年限")); value != "" {
years, err := parsePositiveInt(value)
if err != nil {
return 0, errors.New("续费年限必须为正整数")
}
return years, nil
}
if value := strings.TrimSpace(excelField(row, "续费时长")); value != "" {
years, err := parsePositiveInt(value)
if err != nil {
return 0, errors.New("续费年限必须为正整数")
}
return years, nil
}
if value := strings.TrimSpace(excelField(row, "durationMonths")); value != "" {
years, err := parsePositiveInt(value)
if err != nil {
return 0, errors.New("续费年限必须为正整数")
}
return years, nil
}
return 0, errors.New("续费年限不能为空")
}
func parseExcelPaymentMode(row excel.ParsedRow) (string, error) {
value := strings.TrimSpace(excelField(row, "是否走线下"))
if value == "" {
value = strings.TrimSpace(excelField(row, "付款方式"))
}
if value == "" {
value = strings.TrimSpace(excelField(row, "paymentMode"))
}
if value == "" {
return "", nil
}
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "y", "yes", "true", "offline", "线下", "走线下", "是":
return "offline", nil
case "0", "n", "no", "false", "balance", "余额", "余额支付", "否":
return "balance", nil
default:
return "", errors.New("是否走线下格式不正确")
}
}
func excelField(row excel.ParsedRow, name string) string {
return excel.Field(row, name)
}
func parsePositiveInt(value string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, strconv.ErrSyntax
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return 0, strconv.ErrSyntax
}
return parsed, nil
}
func sameExcelSubmitOptions(left, right *ExcelSubmitOptions) bool {
if left == nil || right == nil {
return false
}
return left.DurationYears == right.DurationYears && strings.TrimSpace(left.PaymentMode) == strings.TrimSpace(right.PaymentMode)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,175 @@
package renew
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"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"
)
// PrepareProcessor 描述 batch_type=4 的卡续费预处理批次。
// 与正式续费 Processor 共享 row/validate 逻辑,但 BatchType/BuildBatchInfo 走 prepare 语义。
type PrepareProcessor struct {
inner *Processor
repo *Repository
config batchimport.ProcessorConfig
}
func NewPrepareProcessor() *PrepareProcessor {
inner := NewProcessor()
cfg := inner.config
cfg.Code = "sim_card_renew_prepare"
cfg.BatchType = consts.BatchTypePrepare
cfg.BatchNoPrefix = consts.BatchNoPrefixRenew
return &PrepareProcessor{
inner: inner,
repo: inner.repo,
config: cfg,
}
}
func (p *PrepareProcessor) Config() batchimport.ProcessorConfig {
return p.config
}
func (p *PrepareProcessor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
return nil, nil
}
func (p *PrepareProcessor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
// Keep submit non-blocking; the worker owns prepare validation.
return nil
}
func (p *PrepareProcessor) Execute(ctx context.Context, tx gdb.TX, req *batchimport.SubmitRequest, rows []batchimport.Row) error {
if req == nil {
return gerror.New("续费预处理请求不能为空")
}
renewRows := make([]*Row, 0, len(rows))
for _, item := range rows {
row, ok := item.(*Row)
if !ok || row == nil {
return gerror.New("续费行类型错误")
}
renewRows = append(renewRows, row)
}
submitParam := renewPrepareSubmitParam(req)
prepared, err := p.inner.ValidatePrepareContext(ctx, tx, submitParam, req.Operator, renewRows)
if err != nil {
return err
}
priceSnapshot, err := ensurePriceSnapshot(ctx, tx, req.Operator, prepared, nil)
if err != nil {
return err
}
req.TargetDeptId = prepared.CustomerAgentId
req.ExecutionResult = priceSnapshot
return nil
}
func (p *PrepareProcessor) BuildBatchInfo(ctx context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
info := g.Map{}
if result != nil && result.ImportFileOssId > 0 {
info["importFileOssId"] = result.ImportFileOssId
} else if importOss != nil && importOss.OssId > 0 {
info["importFileOssId"] = importOss.OssId
}
if resultOss != nil {
info["resultFileOssId"] = resultOss.OssId
}
if downloadId > 0 {
info["resultDownloadId"] = downloadId
}
info["targetBatchType"] = consts.BatchTypeRenew
info["targetBatchName"] = "卡续费"
if in, ok := req.BizParams.(*model.TSimCardRenewBatchPrepareParam); ok && in != nil {
if sourceBatchNo := strings.TrimSpace(in.SourceBatchNo); sourceBatchNo != "" {
info["sourceBatchNo"] = sourceBatchNo
}
}
if in, ok := req.BizParams.(*model.TSimCardRenewBatchPrepareAllParam); ok && in != nil {
if sourceBatchNo := strings.TrimSpace(in.SourceBatchNo); sourceBatchNo != "" {
info["sourceBatchNo"] = sourceBatchNo
}
}
if in, ok := req.BizParams.(*model.TSimCardRenewBatchSubmitParam); ok && in != nil {
durationValue := renewDisplayDurationValue(in)
if durationValue > 0 {
actualDurationMonths := renewActualDurationMonths(nil, durationValue)
info["durationValue"] = durationValue
info["durationYears"] = durationValue
info["durationMonths"] = actualDurationMonths
info["actualDurationMonths"] = actualDurationMonths
}
if paymentMode := strings.TrimSpace(in.PaymentMode); paymentMode != "" {
info["paymentMode"] = paymentMode
}
if packageCode := strings.TrimSpace(in.PackageCode); packageCode != "" {
info["packageCode"] = packageCode
}
info["offlineContractCompleted"] = in.OfflineContractCompleted
if sourceBatchNo := strings.TrimSpace(in.SourceBatchNo); sourceBatchNo != "" {
info["sourceBatchNo"] = sourceBatchNo
}
}
if snapshot, ok := req.ExecutionResult.(*model.TSimCardRenewPriceSnapshot); ok && snapshot != nil {
info["customerAgentId"] = snapshot.CustomerAgentId
info["packageCode"] = snapshot.PackageCode
info["packageName"] = snapshot.PackageName
info["billingMethod"] = snapshot.BillingMethod
info["renewDurationUnit"] = snapshot.RenewDurationUnit
info["renewDurationUnitText"] = snapshot.RenewDurationUnitText
info["priceSource"] = snapshot.PriceSource
info["priceOwnerDeptId"] = snapshot.PriceOwnerDeptId
info["agentDefaultPriceId"] = snapshot.AgentDefaultPriceId
info["targetPriceId"] = snapshot.TargetPriceId
info["renewUnitPrice"] = snapshot.RenewUnitPrice
info["renewableDurationOptions"] = snapshot.RenewableDurationOptions
info["renewablePackageCodes"] = snapshot.RenewablePackageCodes
info["renewablePackages"] = snapshot.RenewablePackages
info["renewBeginDate"] = snapshot.RenewBeginDate
info["renewBeginDateMin"] = snapshot.RenewBeginDateMin
info["renewBeginDateMax"] = snapshot.RenewBeginDateMax
info["sameRenewBeginDate"] = snapshot.SameRenewBeginDate
if result != nil {
info["quantity"] = result.ValidRows
}
}
return gjson.New(info)
}
func (p *PrepareProcessor) ValidatePrepareContext(ctx context.Context, tx gdb.TX, in *model.TSimCardRenewBatchSubmitParam, operator batchimport.OperatorContext, rows []*Row) (*PrepareContext, error) {
return p.inner.ValidatePrepareContext(ctx, tx, in, operator, rows)
}
func renewPrepareSubmitParam(req *batchimport.SubmitRequest) *model.TSimCardRenewBatchSubmitParam {
submitParam := &model.TSimCardRenewBatchSubmitParam{}
if req == nil {
return submitParam
}
switch in := req.BizParams.(type) {
case *model.TSimCardRenewBatchSubmitParam:
if in != nil {
submitParam = in
}
case *model.TSimCardRenewBatchPrepareParam:
if in != nil {
submitParam.SourceBatchNo = strings.TrimSpace(in.SourceBatchNo)
}
case *model.TSimCardRenewBatchPrepareAllParam:
if in != nil {
submitParam.SourceBatchNo = strings.TrimSpace(in.SourceBatchNo)
}
}
if strings.TrimSpace(submitParam.SourceBatchNo) == "" {
submitParam.SourceBatchNo = strings.TrimSpace(req.SourceBatchNo)
}
return submitParam
}
@@ -0,0 +1,558 @@
package renew
import (
"context"
"sort"
"strconv"
"strings"
"time"
"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/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"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"
"github.com/gogf/gf/v2/util/gconv"
)
type Processor struct {
repo *Repository
config batchimport.ProcessorConfig
}
func NewProcessor() *Processor {
return &Processor{
repo: NewRepository(),
config: batchimport.ProcessorConfig{
Code: "sim_card_renew",
BatchType: consts.BatchTypeRenew,
BatchNoPrefix: consts.BatchNoPrefixRenew,
TaskName: consts.ResourceBatchDownloadTaskSimCardRenew,
ExportScene: consts.ResourceExportSceneRenew,
ImportDir: consts.SimCardRenewSubDirImportExcel,
ResultDir: consts.SimCardRenewSubDirResultExcel,
MaxFileSize: consts.SimCardRenewMaxFileSize,
TemplateHeaders: ExcelTemplateHeaders(),
RequiredHeaders: []string{"ICCID"},
},
}
}
func (p *Processor) Config() batchimport.ProcessorConfig {
return p.config
}
func (p *Processor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
return nil, gerror.New("卡续费不支持文件导入")
}
func (p *Processor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
renewRows := make([]*Row, 0, len(rows))
for _, item := range rows {
row, ok := item.(*Row)
if !ok {
item.AddErrorReason("续费行类型错误")
continue
}
renewRows = append(renewRows, row)
}
if err := normalizeRenewIdentityRows(ctx, renewRows); err != nil {
return err
}
return validateRows(renewRows)
}
func (p *Processor) Execute(ctx context.Context, tx gdb.TX, req *batchimport.SubmitRequest, rows []batchimport.Row) error {
if req == nil {
return gerror.New("续费请求不能为空")
}
in, ok := req.BizParams.(*model.TSimCardRenewBatchSubmitParam)
if !ok || in == nil {
return gerror.New("续费参数类型错误")
}
renewRows := make([]*Row, 0, len(rows))
for _, item := range rows {
row, ok := item.(*Row)
if !ok {
return gerror.New("续费行类型错误")
}
renewRows = append(renewRows, row)
}
options, err := loadExecutionOptions(ctx, tx, req.BatchNo)
if err != nil {
return err
}
applyExecutionOptions(in, options)
prepared, err := p.validateRenewContext(ctx, tx, in, req.Operator, renewRows, req.BatchNo, false)
if err != nil {
return err
}
priceSnapshot, err := ensurePriceSnapshot(ctx, tx, req.Operator, prepared, options.PriceSnapshot)
if err != nil {
return err
}
durationValue := renewDisplayDurationValue(in)
if durationValue <= 0 {
return gerror.New("续费时长必须大于0")
}
if !containsRenewDurationOption(priceSnapshot.RenewableDurationOptions, durationValue) {
return gerror.New("续费时长不在当前套餐可续费范围内")
}
output, err := p.Complete(ctx, tx, &CompleteInput{
SubmitParam: in,
BatchNo: req.BatchNo,
Rows: renewRows,
Operator: req.Operator,
PriceSnapshot: priceSnapshot,
})
if err != nil {
return err
}
req.ExecutionResult = output
return nil
}
func (p *Processor) BuildBatchInfo(ctx context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
info := g.Map{}
if result != nil && result.ImportFileOssId > 0 {
info["importFileOssId"] = result.ImportFileOssId
} else if importOss != nil && importOss.OssId > 0 {
info["importFileOssId"] = importOss.OssId
}
if resultOss != nil {
info["resultFileOssId"] = resultOss.OssId
}
if downloadId > 0 {
info["resultDownloadId"] = downloadId
}
if in, ok := req.BizParams.(*model.TSimCardRenewBatchSubmitParam); ok && in != nil {
durationValue := renewDisplayDurationValue(in)
if durationValue > 0 {
actualDurationMonths := renewActualDurationMonths(nil, durationValue)
info["durationYears"] = durationValue
info["durationMonths"] = actualDurationMonths
info["durationValue"] = durationValue
info["actualDurationMonths"] = actualDurationMonths
}
info["giftDurationMonths"] = in.GiftDurationMonths
info["amount"] = strings.TrimSpace(in.Amount)
if in.PaymentSnapshot != nil {
appendRenewPaymentSnapshot(info, in.PaymentSnapshot)
} else if paymentMode := strings.TrimSpace(in.PaymentMode); paymentMode != "" {
info["paymentMode"] = paymentMode
}
if packageCode := strings.TrimSpace(in.PackageCode); packageCode != "" {
info["packageCode"] = packageCode
}
info["offlineContractCompleted"] = in.OfflineContractCompleted
if sourceBatchNo := strings.TrimSpace(in.SourceBatchNo); sourceBatchNo != "" {
info["sourceBatchNo"] = sourceBatchNo
}
if buyerNote := strings.TrimSpace(in.BuyerNote); buyerNote != "" {
info["buyerNote"] = buyerNote
}
if adminNote := strings.TrimSpace(in.AdminNote); adminNote != "" {
info["adminNote"] = adminNote
}
if remark := strings.TrimSpace(in.Remark); remark != "" {
info["remark"] = remark
}
}
if snapshot, ok := req.ExecutionResult.(*model.TSimCardRenewPriceSnapshot); ok && snapshot != nil {
if result != nil && result.BatchType == consts.BatchTypePrepare {
info["targetBatchType"] = consts.BatchTypeRenew
info["targetBatchName"] = "卡续费"
}
info["customerAgentId"] = snapshot.CustomerAgentId
info["packageCode"] = snapshot.PackageCode
info["packageName"] = snapshot.PackageName
info["billingMethod"] = snapshot.BillingMethod
info["renewDurationUnit"] = snapshot.RenewDurationUnit
info["renewDurationUnitText"] = snapshot.RenewDurationUnitText
if durationValue := gconv.Int(info["durationValue"]); durationValue > 0 {
actualDurationMonths := renewActualDurationMonths(snapshot, durationValue)
info["durationMonths"] = actualDurationMonths
info["actualDurationMonths"] = actualDurationMonths
}
info["quantity"] = result.ValidRows
info["priceSource"] = snapshot.PriceSource
info["priceOwnerDeptId"] = snapshot.PriceOwnerDeptId
info["agentDefaultPriceId"] = snapshot.AgentDefaultPriceId
info["targetPriceId"] = snapshot.TargetPriceId
info["renewUnitPrice"] = snapshot.RenewUnitPrice
info["renewableDurationOptions"] = snapshot.RenewableDurationOptions
info["renewablePackageCodes"] = snapshot.RenewablePackageCodes
info["renewablePackages"] = snapshot.RenewablePackages
info["renewBeginDate"] = snapshot.RenewBeginDate
info["renewBeginDateMin"] = snapshot.RenewBeginDateMin
info["renewBeginDateMax"] = snapshot.RenewBeginDateMax
info["sameRenewBeginDate"] = snapshot.SameRenewBeginDate
}
if output, ok := req.ExecutionResult.(*CompleteOutput); ok && output != nil {
info["quantity"] = output.Quantity
info["packageCode"] = output.PackageCode
info["orderSn"] = output.OrderSn
info["renewTime"] = output.RenewTime
if sourceBatchNo := strings.TrimSpace(output.SourceBatchNo); sourceBatchNo != "" {
info["sourceBatchNo"] = sourceBatchNo
}
}
return gjson.New(info)
}
func appendRenewPaymentSnapshot(info g.Map, snapshot *model.TSimCardRenewPaymentSnapshot) {
if info == nil || snapshot == nil {
return
}
info["paymentMode"] = strings.TrimSpace(snapshot.PaymentMode)
info["payType"] = snapshot.PayType
info["payStatus"] = snapshot.PayStatus
info["payAmount"] = snapshot.PayAmount
info["balanceAmount"] = snapshot.BalanceAmount
info["offlinePaidAmount"] = snapshot.OfflinePaidAmount
info["balanceBefore"] = snapshot.BalanceBefore
info["balanceAfter"] = snapshot.BalanceAfter
info["balanceDetailSerialNo"] = strings.TrimSpace(snapshot.BalanceDetailSerialNo)
info["payerDeptId"] = snapshot.PayerDeptId
info["payerUserId"] = snapshot.PayerUserId
info["isTopDept"] = snapshot.IsTopDept
if snapshot.PaymentTime != nil {
info["paymentTime"] = snapshot.PaymentTime.Format("Y-m-d H:i:s")
}
info["paymentSnapshot"] = snapshot
}
func (p *Processor) ValidatePrepareContext(ctx context.Context, tx gdb.TX, in *model.TSimCardRenewBatchSubmitParam, operator batchimport.OperatorContext, rows []*Row) (*PrepareContext, error) {
return p.validateRenewContext(ctx, tx, in, operator, rows, "", false)
}
func (p *Processor) RevalidatePrepareContext(ctx context.Context, tx gdb.TX, in *model.TSimCardRenewBatchSubmitParam, operator batchimport.OperatorContext, rows []*Row, currentBatchNo string) (*PrepareContext, bool, error) {
prepared, err := p.validateRenewContext(ctx, tx, in, operator, rows, currentBatchNo, true)
return prepared, hasInvalidRows(rows), err
}
func (p *Processor) validateRenewContext(ctx context.Context, tx gdb.TX, in *model.TSimCardRenewBatchSubmitParam, operator batchimport.OperatorContext, rows []*Row, currentBatchNo string, allowPartial bool) (*PrepareContext, error) {
iccids := rowsToIccids(rows)
cardsByIccid, err := p.repo.LockCardsByIccids(ctx, tx, iccids)
if err != nil {
return nil, gerror.Wrap(err, "校验续费卡信息失败")
}
bindsByIccid, err := p.repo.LockLatestBindsByIccids(ctx, tx, iccids)
if err != nil {
return nil, gerror.Wrap(err, "校验续费订购记录失败")
}
conflictingBatches, err := p.repo.UnfinishedRenewBatchNosByIccids(
ctx,
tx,
operator.TenantId,
iccids,
currentBatchNo,
)
if err != nil {
return nil, gerror.Wrap(err, "校验未完成续费批次失败")
}
sourceBatchNo := strings.TrimSpace(in.SourceBatchNo)
packageCodes := make(map[string]struct{})
customerAgentIds := make(map[int64]struct{})
currentPackagePeriods := make(map[string]struct{})
renewBeginDates := make([]string, 0, len(rows))
packageCode := ""
customerAgentId := int64(0)
for _, row := range rows {
if row == nil || row.Reason != "" {
continue
}
card := cardsByIccid[row.Iccid]
bind := bindsByIccid[row.Iccid]
if strings.TrimSpace(conflictingBatches[row.Iccid]) != "" {
row.AddReason("存在未完成续费批次,不能重复续费")
continue
}
reason := validateRenewRow(sourceBatchNo, operator, card, bind)
if reason != "" {
row.AddReason(reason)
continue
}
baseExpireAt := renewBaseExpireTime(card, bind)
periodKey := currentPackagePeriodKeyWithExpire(bind, baseExpireAt)
if periodKey == "" {
row.AddReason("不是统一批次")
continue
}
currentPackageCode := strings.TrimSpace(card.CurrentPackageCode)
packageCodes[currentPackageCode] = struct{}{}
customerAgentIds[card.DeptId] = struct{}{}
currentPackagePeriods[periodKey] = struct{}{}
packageCode = currentPackageCode
customerAgentId = card.DeptId
renewBeginAt := renewBeginDateFromExpire(baseExpireAt)
if renewBeginAt != nil {
renewBeginDates = append(renewBeginDates, renewBeginAt.Format("Y-m-d"))
}
}
if len(packageCodes) > 1 {
markRowsWithoutReason(rows, "当前批次下存在多个套餐,不能统一续费")
}
if len(customerAgentIds) > 1 {
markRowsWithoutReason(rows, "当前批次下存在多个客户,不能统一续费")
}
if len(currentPackagePeriods) > 1 {
markRowsWithoutReason(rows, "不是统一批次")
}
if len(customerAgentIds) == 1 && customerAgentId > 0 {
if err := validateRenewCustomerDeptScope(ctx, tx, operator, customerAgentId); err != nil {
markRowsWithoutReason(rows, err.Error())
}
}
if hasInvalidRows(rows) && !allowPartial {
markRemainingRowsSkipped(rows)
return nil, gerror.New("存在不满足续费条件的卡,本次未执行")
}
validCount := countValidRows(rows)
if validCount == 0 {
return &PrepareContext{}, nil
}
renewBeginDate, renewBeginDateMin, renewBeginDateMax, sameRenewBeginDate := summarizeRenewBeginDates(renewBeginDates)
return &PrepareContext{
CustomerAgentId: customerAgentId,
PackageCode: packageCode,
Quantity: validCount,
RenewBeginDate: renewBeginDate,
RenewBeginDateMin: renewBeginDateMin,
RenewBeginDateMax: renewBeginDateMax,
SameRenewBeginDate: sameRenewBeginDate,
}, nil
}
func currentPackagePeriodKey(bind *entity.TSimCardBindPkg) string {
if bind == nil {
return ""
}
return currentPackagePeriodKeyWithExpire(bind, bind.ExpectedExpireTime)
}
func currentPackagePeriodKeyWithExpire(bind *entity.TSimCardBindPkg, expireAt *gtime.Time) string {
if bind == nil || bind.EffectiveTime == nil {
return ""
}
if expireAt == nil {
expireAt = bind.ExpectedExpireTime
}
if expireAt == nil {
return ""
}
return bind.EffectiveTime.Format("Y-m-d") + "|" + expireAt.Format("Y-m-d")
}
func summarizeRenewBeginDates(dates []string) (sameDate string, minDate string, maxDate string, same bool) {
cleaned := make([]string, 0, len(dates))
for _, date := range dates {
date = strings.TrimSpace(date)
if date != "" {
cleaned = append(cleaned, date)
}
}
if len(cleaned) == 0 {
return "", "", "", false
}
sort.Strings(cleaned)
minDate = cleaned[0]
maxDate = cleaned[len(cleaned)-1]
same = minDate == maxDate
if same {
sameDate = minDate
}
return sameDate, minDate, maxDate, same
}
func renewBeginDateFromExpire(expireAt *gtime.Time) *gtime.Time {
if expireAt == nil {
return nil
}
return normalizeRenewDateAtDayEnd(expireAt)
}
func normalizeRenewDateAtDayEnd(expireAt *gtime.Time) *gtime.Time {
if expireAt == nil || expireAt.IsZero() {
return nil
}
t := expireAt.Time
return gtime.NewFromTime(
time.Date(
t.Year(),
t.Month(),
t.Day(),
23,
59,
59,
0,
t.Location(),
),
)
}
func validateRenewRow(sourceBatchNo string, operator batchimport.OperatorContext, card *entity.TSimCardInfo, bind *entity.TSimCardBindPkg) string {
if card == nil {
return "ICCID不存在正式卡记录,不能续费"
}
if card.BossCardStatus == consts.V3ApiSimBossCardStatusStopped {
return "ICCID当前为停机状态,不能续费"
}
if bind == nil {
return "ICCID没有订购记录,不能续费"
}
if strings.TrimSpace(card.CurrentPackageCode) == "" || card.PackageStatus == consts.PackageStatusNone {
return "ICCID没有当前套餐,不能续费"
}
if strings.TrimSpace(card.CurrentPackageCode) != strings.TrimSpace(bind.PackageCode) {
return "ICCID当前套餐与最新订购记录不一致,不能续费"
}
baseExpireAt := renewBaseExpireTime(card, bind)
if baseExpireAt == nil {
return "ICCID最新订购没有预计到期时间,不能续费"
}
if renewExpireAfterAllowedWindow(baseExpireAt) {
return "距离到期超过6个月,不能续费"
}
if sourceBatchNo != "" && strings.TrimSpace(card.OutboundBatchNo) != sourceBatchNo {
return "ICCID不属于来源出库批次,不能续费"
}
return ""
}
func renewBaseExpireTime(card *entity.TSimCardInfo, bind *entity.TSimCardBindPkg) *gtime.Time {
var expireAt *gtime.Time
if bind != nil {
expireAt = laterRenewExpireTime(expireAt, bind.ExpectedExpireTime)
}
if card != nil {
expireAt = laterRenewExpireTime(expireAt, card.FlowPkgEndAt)
expireAt = laterRenewExpireTime(expireAt, card.FlowPkgPresentAt)
expireAt = laterRenewExpireTime(expireAt, card.FlowPkgRealPresentAt)
}
return expireAt
}
func laterRenewExpireTime(current, candidate *gtime.Time) *gtime.Time {
if candidate == nil || candidate.IsZero() {
return current
}
if current == nil || current.IsZero() || candidate.After(current) {
return candidate
}
return current
}
func renewExpireAfterAllowedWindow(expireAt *gtime.Time) bool {
if expireAt == nil {
return false
}
return expireAt.After(gtime.Now().AddDate(0, 6, 0))
}
func validateRenewCustomerDeptScope(ctx context.Context, tx gdb.TX, operator batchimport.OperatorContext, customerDeptID int64) error {
if operator.DeptId <= 0 || operator.TenantId == "" {
return gerror.New("当前用户未绑定组织或租户")
}
if customerDeptID <= 0 {
return gerror.New("ICCID不属于当前机构,不能续费")
}
if customerDeptID == operator.DeptId {
return nil
}
dept := &entity.SysDept{}
if err := tx.Ctx(ctx).Model(dao.SysDept.Table()).
Fields(dao.SysDept.Columns().DeptId, dao.SysDept.Columns().ParentId, dao.SysDept.Columns().Ancestors).
Where(dao.SysDept.Columns().TenantId, operator.TenantId).
Where(dao.SysDept.Columns().DeptId, customerDeptID).
WhereNull(dao.SysDept.Columns().DeletedAt).
Scan(dept); err != nil {
return gerror.Wrap(err, "校验ICCID归属失败")
}
if dept.DeptId == 0 {
return gerror.New("ICCID不属于当前机构,不能续费")
}
if dept.ParentId == operator.DeptId || renewDeptAncestorsContain(dept.Ancestors, operator.DeptId) {
return nil
}
return gerror.New("ICCID不属于当前机构,不能续费")
}
func renewDeptAncestorsContain(ancestors string, deptID int64) bool {
if deptID <= 0 {
return false
}
target := strconv.FormatInt(deptID, 10)
for _, item := range strings.Split(strings.Trim(ancestors, ","), ",") {
if strings.TrimSpace(item) == target {
return true
}
}
return false
}
func hasInvalidRows(rows []*Row) bool {
for _, row := range rows {
if row != nil && row.Reason != "" {
return true
}
}
return false
}
func countValidRows(rows []*Row) int {
count := 0
for _, row := range rows {
if row != nil && row.Valid() {
count++
}
}
return count
}
func markRowsWithoutReason(rows []*Row, reason string) {
for _, row := range rows {
if row != nil && row.Reason == "" {
row.AddReason(reason)
}
}
}
func markRemainingRowsSkipped(rows []*Row) {
for _, row := range rows {
if row != nil && row.Reason == "" {
row.AddReason("同批存在不满足续费条件的卡,本行未执行")
}
}
}
func validateRows(rows []*Row) error {
firstByIccid := make(map[string]*Row, len(rows))
for _, row := range rows {
row.Iccid = strings.TrimSpace(row.Iccid)
if row.Iccid == "" {
row.AddReason("ICCID不能为空")
continue
}
if first, ok := firstByIccid[row.Iccid]; ok {
row.AddReason("ICCID重复")
first.AddReason("ICCID重复")
continue
}
firstByIccid[row.Iccid] = row
}
return nil
}
@@ -0,0 +1,299 @@
package renew
import (
"context"
"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/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
)
type Repository struct{}
func NewRepository() *Repository {
return &Repository{}
}
func (r *Repository) GetSourceBatch(ctx context.Context, batchNo string) (*entity.BatchMain, error) {
batch := &entity.BatchMain{}
columns := dao.BatchMain.Columns()
err := dao.BatchMain.Ctx(ctx).
Fields(
columns.Id,
columns.BatchNo,
columns.BatchType,
columns.BatchStatus,
columns.RunResult,
columns.TenantId,
columns.DeptId,
columns.CreatedDept,
columns.CreatedBy,
).
Where(columns.BatchNo, strings.TrimSpace(batchNo)).
Scan(batch)
if err != nil {
return nil, err
}
if batch.Id == 0 {
return nil, nil
}
return batch, nil
}
func (r *Repository) GetRenewBatchRows(ctx context.Context, batchNo string, batchType int) ([]*Row, error) {
return r.getRenewBatchRows(ctx, nil, batchNo, batchType)
}
func (r *Repository) GetRenewBatchRowsTx(ctx context.Context, tx gdb.TX, batchNo string, batchType int) ([]*Row, error) {
return r.getRenewBatchRows(ctx, tx, batchNo, batchType)
}
func (r *Repository) getRenewBatchRows(ctx context.Context, tx gdb.TX, batchNo string, batchType int) ([]*Row, error) {
var details []*entity.BatchDetail
columns := dao.BatchDetail.Columns()
if batchType == 0 {
batchType = consts.BatchTypeRenew
}
model := dao.BatchDetail.Ctx(ctx)
if tx != nil {
model = tx.Ctx(ctx).Model(dao.BatchDetail.Table())
}
err := model.
Fields(columns.RowNo, columns.ItemId, columns.Snapshot).
Where(columns.BatchNo, strings.TrimSpace(batchNo)).
Where(columns.BatchType, batchType).
Where(columns.DetailStatus, consts.BatchDetailStatusSuccess).
OrderAsc(columns.RowNo).
Scan(&details)
if err != nil {
return nil, err
}
return detailsToRows(details), nil
}
func (r *Repository) GetSourceBatchRows(ctx context.Context, batchNo string) ([]*Row, error) {
var details []*entity.BatchDetail
columns := dao.BatchDetail.Columns()
err := dao.BatchDetail.Ctx(ctx).
Fields(columns.RowNo, columns.ItemId, columns.Snapshot).
Where(columns.BatchNo, strings.TrimSpace(batchNo)).
Where(columns.BatchType, consts.BatchTypeOutbound).
WhereIn(columns.DetailStatus, []int{consts.BatchDetailStatusSuccess, consts.BatchAsyncStatusSuccess}).
OrderAsc(columns.RowNo).
Scan(&details)
if err != nil {
return nil, err
}
return detailsToRows(details), nil
}
func (r *Repository) BatchDetailItemIDs(ctx context.Context, tx gdb.TX, batchNo string, batchType int, statuses []int) ([]string, error) {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return nil, nil
}
var details []*entity.BatchDetail
model := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Fields(dao.BatchDetail.Columns().ItemId).
Where(dao.BatchDetail.Columns().BatchNo, batchNo)
if batchType > 0 {
model = model.Where(dao.BatchDetail.Columns().BatchType, batchType)
}
if len(statuses) > 0 {
model = model.WhereIn(dao.BatchDetail.Columns().DetailStatus, statuses)
}
if err := model.Scan(&details); err != nil {
return nil, err
}
ids := make([]string, 0, len(details))
for _, detail := range details {
if detail == nil {
continue
}
if itemID := strings.TrimSpace(detail.ItemId); itemID != "" {
ids = append(ids, itemID)
}
}
return uniqueStrings(ids), nil
}
func (r *Repository) UnfinishedRenewBatchNosByIccids(ctx context.Context, tx gdb.TX, tenantID string, iccids []string, currentBatchNo string) (map[string]string, error) {
result := make(map[string]string)
iccids = uniqueStrings(iccids)
if len(iccids) == 0 {
return result, nil
}
currentBatchID, err := r.batchIDByNo(ctx, tx, currentBatchNo)
if err != nil {
return nil, err
}
type conflictRow struct {
ItemId string `orm:"item_id"`
BatchNo string `orm:"batch_no"`
}
bd := "bd"
bm := "bm"
detailColumns := dao.BatchDetail.Columns()
mainColumns := dao.BatchMain.Columns()
model := tx.Ctx(ctx).Model(dao.BatchDetail.Table()+" "+bd).
InnerJoin(dao.BatchMain.Table()+" "+bm, bm+"."+mainColumns.BatchNo+" = "+bd+"."+detailColumns.BatchNo).
Fields(bd+"."+detailColumns.ItemId, bm+"."+mainColumns.BatchNo).
Where(bd+"."+detailColumns.BatchType, consts.BatchTypeRenew).
Where(bm+"."+mainColumns.BatchType, consts.BatchTypeRenew).
Where(bm+"."+mainColumns.BatchStatus, consts.BatchStatusNormal).
WhereIn(bm+"."+mainColumns.BatchAsyncStatus, []int{
consts.BatchAsyncStatusCreated,
consts.BatchAsyncStatusPending,
consts.BatchAsyncStatusProcessing,
}).
WhereIn(bm+"."+mainColumns.RunResult, []int{
consts.BatchRunResultPending,
consts.BatchRunResultRunning,
}).
WhereIn(bd+"."+detailColumns.DetailStatus, []int{
consts.BatchDetailStatusPending,
consts.BatchDetailStatusRunning,
}).
WhereIn(bd+"."+detailColumns.ItemId, iccids).
OrderAsc(bm + "." + mainColumns.Id)
if strings.TrimSpace(tenantID) != "" {
model = model.Where(bm+"."+mainColumns.TenantId, strings.TrimSpace(tenantID))
}
if currentBatchID > 0 {
// When duplicate formal renew batches already exist, the earliest batch
// wins and later batches fail. This prevents two batches from blocking
// each other forever.
model = model.Where(bm+"."+mainColumns.Id+" < ?", currentBatchID)
} else if currentBatchNo = strings.TrimSpace(currentBatchNo); currentBatchNo != "" {
model = model.Where(bm+"."+mainColumns.BatchNo+" <> ?", currentBatchNo)
}
var rows []conflictRow
if err := model.Scan(&rows); err != nil {
return nil, err
}
for _, row := range rows {
itemID := strings.TrimSpace(row.ItemId)
if itemID == "" {
continue
}
if _, exists := result[itemID]; !exists {
result[itemID] = strings.TrimSpace(row.BatchNo)
}
}
return result, nil
}
func (r *Repository) batchIDByNo(ctx context.Context, tx gdb.TX, batchNo string) (int64, error) {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return 0, nil
}
batch := &entity.BatchMain{}
if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Fields(dao.BatchMain.Columns().Id).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Limit(1).
Scan(batch); err != nil {
return 0, err
}
return batch.Id, nil
}
func detailsToRows(details []*entity.BatchDetail) []*Row {
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 {
if row.Iccid == "" {
row.Iccid = strings.TrimSpace(detail.Snapshot.Get("iccid").String())
}
if row.Iccid == "" {
row.Iccid = strings.TrimSpace(detail.Snapshot.Get("ICCID").String())
}
row.Msisdn = strings.TrimSpace(detail.Snapshot.Get("msisdn").String())
if row.Msisdn == "" {
row.Msisdn = strings.TrimSpace(detail.Snapshot.Get("MSISDN").String())
}
row.Imsi = strings.TrimSpace(detail.Snapshot.Get("imsi").String())
if row.Imsi == "" {
row.Imsi = strings.TrimSpace(detail.Snapshot.Get("IMSI").String())
}
}
rows = append(rows, row)
}
return rows
}
func (r *Repository) LockCardsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*entity.TSimCardInfo, error) {
result := make(map[string]*entity.TSimCardInfo)
if len(iccids) == 0 {
return result, nil
}
var list []*entity.TSimCardInfo
if err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
WhereIn(dao.TSimCardInfo.Columns().Iccid, uniqueStrings(iccids)).
LockUpdate().
Scan(&list); err != nil {
return nil, err
}
for _, item := range list {
if item != nil && strings.TrimSpace(item.Iccid) != "" {
result[strings.TrimSpace(item.Iccid)] = item
}
}
return result, nil
}
func (r *Repository) LockLatestBindsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*entity.TSimCardBindPkg, error) {
result := make(map[string]*entity.TSimCardBindPkg)
if len(iccids) == 0 {
return result, nil
}
var list []*entity.TSimCardBindPkg
if err := tx.Ctx(ctx).Model(dao.TSimCardBindPkg.Table()).
WhereIn(dao.TSimCardBindPkg.Columns().Iccid, uniqueStrings(iccids)).
WhereIn(dao.TSimCardBindPkg.Columns().PackageType, []int{101, 102}).
WhereIn(dao.TSimCardBindPkg.Columns().EffectiveStatus, []int{
consts.EffectiveStatusPending,
consts.EffectiveStatusEffective,
consts.EffectiveStatusExpired,
}).
OrderDesc(dao.TSimCardBindPkg.Columns().ExpectedExpireTime).
OrderDesc(dao.TSimCardBindPkg.Columns().Id).
LockUpdate().
Scan(&list); err != nil {
return nil, err
}
effectiveCount := make(map[string]int)
for _, item := range list {
if item == nil || strings.TrimSpace(item.Iccid) == "" {
continue
}
iccid := strings.TrimSpace(item.Iccid)
if item.EffectiveStatus == consts.EffectiveStatusEffective {
effectiveCount[iccid]++
if effectiveCount[iccid] > 1 {
return nil, gerror.Newf("同一张卡存在多个生效中的套餐订购记录:%s", iccid)
}
}
if _, exists := result[iccid]; exists {
continue
}
result[iccid] = item
}
return result, nil
}
@@ -0,0 +1,99 @@
package renew
import (
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
type Row struct {
RowNo int
InputCode string
Iccid string
Msisdn string
Imsi string
Reason string
}
func (r *Row) Valid() bool {
return r != nil && r.Reason == ""
}
func (r *Row) AddReason(reason string) {
if r == nil || r.Reason != "" || reason == "" {
return
}
r.Reason = reason
}
func (r *Row) GetRowNo() int {
if r == nil {
return 0
}
return r.RowNo
}
func (r *Row) GetItemID() string {
if r == nil {
return ""
}
return r.Iccid
}
func (r *Row) GetErrorReasons() []string {
if r == nil || r.Reason == "" {
return nil
}
return []string{r.Reason}
}
func (r *Row) AddErrorReason(reason string) {
r.AddReason(reason)
}
func (r *Row) Snapshot() *gjson.Json {
if r == nil {
return gjson.New(g.Map{})
}
return gjson.New(g.Map{
"ICCID": r.Iccid,
"MSISDN": r.Msisdn,
"IMSI": r.Imsi,
})
}
type CompleteInput struct {
SubmitParam *model.TSimCardRenewBatchSubmitParam
BatchNo string
Rows []*Row
Operator batchimport.OperatorContext
PriceSnapshot *model.TSimCardRenewPriceSnapshot
RenewDate *gtime.Time
}
type CompleteOutput struct {
BatchNo string
BatchType int
SourceBatchNo string
Quantity int
PackageCode string
PackageName string
OrderSn string
Amount float64
RenewUnitPrice float64
RenewDate *gtime.Time
RenewTime *gtime.Time
}
type PrepareContext struct {
CustomerAgentId int64
PackageCode string
Quantity int
RenewBeginDate string
RenewBeginDateMin string
RenewBeginDateMax string
SameRenewBeginDate bool
}
@@ -0,0 +1,27 @@
package renew
import (
"sort"
"strings"
)
func uniqueStrings(list []string) []string {
if len(list) == 0 {
return nil
}
seen := make(map[string]struct{}, len(list))
result := make([]string, 0, len(list))
for _, item := range list {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
sort.Strings(result)
return result
}