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,274 @@
package outbound
import (
"context"
"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"`
}
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
)
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 {
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 {
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 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
}
@@ -0,0 +1,777 @@
package outbound
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"sort"
"strconv"
"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/logic/iot/cardpackage"
"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"
)
const (
outboundPaymentModeOffline = "offline"
outboundPaymentModeBalance = "balance"
outboundDetailUpdateChunkSize = 200
outboundFinanceIncomeExpenseExpense = 1
outboundFinanceTransactionTypeConsume = 1
outboundFinanceTransactionChannelBalance = 3
outboundFinanceTransactionStatusPaid = 1
outboundFinanceAccountStatusNormal = 0
)
func (p *Processor) Complete(ctx context.Context, tx gdb.TX, in *CompleteInput) (*CompleteOutput, error) {
if in == nil || in.SubmitParam == nil {
return nil, gerror.New("出库参数不能为空")
}
if in.BatchNo == "" {
return nil, gerror.New("出库批次号不能为空")
}
if len(in.Rows) == 0 {
return nil, gerror.New("出库卡不能为空")
}
normalizeSubmitParam(in.SubmitParam)
if err := validateSubmitParam(in.SubmitParam); err != nil {
return nil, err
}
iccids, err := strictRowsToIccids(in.Rows)
if err != nil {
return nil, err
}
stockByIccid, err := p.repo.LockStockCardsByIccids(ctx, tx, iccids)
if err != nil {
return nil, gerror.Wrap(err, "锁定库存卡失败")
}
if len(stockByIccid) != len(iccids) {
return nil, gerror.New("部分库存卡已变化,请刷新后重试")
}
for _, iccid := range iccids {
stock := stockByIccid[iccid]
if stock == nil {
return nil, gerror.Newf("ICCID不存在库存记录:%s", iccid)
}
if stock.CreatedDept != in.Operator.DeptId {
return nil, gerror.Newf("ICCID不属于当前机构库存:%s", iccid)
}
if in.RequiredSourceBatchNo != "" && strings.TrimSpace(stock.BatchNo) != in.RequiredSourceBatchNo {
return nil, gerror.Newf("ICCID 不属于指定批次:%s", iccid)
}
}
if in.StrictStockIdentity {
for _, row := range in.Rows {
stock := stockByIccid[row.Iccid]
if stock == nil {
return nil, gerror.Newf("ICCID不存在库存记录:%s", row.Iccid)
}
if strings.TrimSpace(row.Msisdn) != strings.TrimSpace(stock.Msisdn) {
return nil, gerror.Newf("MSISDN与库存记录不一致:%s", row.Iccid)
}
if strings.TrimSpace(row.Imsi) != strings.TrimSpace(stock.Imsi) {
return nil, gerror.Newf("IMSI与库存记录不一致:%s", row.Iccid)
}
}
}
formalByIccid, err := p.repo.GetExistingFormalIccids(ctx, iccids)
if err != nil {
return nil, gerror.Wrap(err, "查询正式卡失败")
}
for _, iccid := range iccids {
if formalByIccid[iccid] {
return nil, gerror.Newf("ICCID已存在正式卡记录:%s", iccid)
}
}
quantity := len(iccids)
subscribeCards := make([]cardpackage.SubscribeCard, 0, quantity)
cardRows := make([]do.TSimCardInfo, 0, quantity)
detailUpdates := make(map[string]*gjsonJsonHolder, quantity)
sourceBatchNos := make([]string, 0, quantity)
sourceBatchSet := make(map[string]struct{}, quantity)
for _, row := range in.Rows {
stock := stockByIccid[row.Iccid]
if stock == nil {
return nil, gerror.Newf("ICCID不存在库存记录:%s", row.Iccid)
}
if strings.TrimSpace(stock.Iccid) != strings.TrimSpace(row.Iccid) {
return nil, gerror.Newf("库存卡映射异常:第%d行%s匹配到%s", row.RowNo, row.Iccid, stock.Iccid)
}
row.SourceBatchNo = strings.TrimSpace(stock.BatchNo)
row.Msisdn = chooseString(row.Msisdn, stock.Msisdn)
row.Imsi = chooseString(row.Imsi, stock.Imsi)
if row.SourceBatchNo != "" {
if _, ok := sourceBatchSet[row.SourceBatchNo]; !ok {
sourceBatchSet[row.SourceBatchNo] = struct{}{}
sourceBatchNos = append(sourceBatchNos, row.SourceBatchNo)
}
}
subscribeCards = append(subscribeCards, cardpackage.SubscribeCard{
Iccid: stock.Iccid,
Msisdn: stock.Msisdn,
Imsi: stock.Imsi,
})
}
inboundBatchInfos, err := p.repo.GetInboundBatchInfos(ctx, tx, sourceBatchNos)
if err != nil {
return nil, gerror.Wrap(err, "查询入库批次信息失败")
}
payment, err := prepareOutboundPayment(ctx, tx, in.BatchNo, subscriptionAmount(in.SubmitParam), in.Operator, in.SubmitParam)
if err != nil {
return nil, err
}
packageSvc := cardpackage.NewService()
subscription, err := packageSvc.CreatePackageSubscription(ctx, tx, &cardpackage.CreatePackageSubscriptionInput{
BatchNo: in.BatchNo,
Cards: subscribeCards,
CustomerAgentId: in.SubmitParam.CustomerAgentId,
PackageCode: strings.TrimSpace(in.SubmitParam.PackageCode),
PackageBeginTime: in.SubmitParam.PackageBeginTime,
DurationMonths: in.SubmitParam.DurationMonths,
GiftDurationMonths: in.SubmitParam.GiftDurationMonths,
DurationDisplayValue: displayDurationValue(in.SubmitParam.DurationYears, in.SubmitParam.DurationMonths),
GiftDurationDisplayValue: displayDurationValue(in.SubmitParam.GiftDurationYears, in.SubmitParam.GiftDurationMonths),
DurationDisplayLabel: displayDurationLabel(in.SubmitParam.DurationYears, in.SubmitParam.DurationMonthPart, in.SubmitParam.DurationMonths),
GiftDurationDisplayLabel: displayDurationLabel(in.SubmitParam.GiftDurationYears, in.SubmitParam.GiftDurationMonthPart, in.SubmitParam.GiftDurationMonths),
DurationUnit: consts.RenewDurationUnitYear,
EndAtPreviousMonthEnd: true,
Amount: in.SubmitParam.Amount,
PayType: payment.PayType,
PayStatus: payment.PayStatus,
BalanceAmount: payment.BalanceAmount,
OfflinePaidAmount: payment.OfflinePaidAmount,
PayTime: payment.PaymentTime,
OfflineContractCompleted: in.SubmitParam.OfflineContractCompleted,
BuyerNote: normalizedRemark(in.SubmitParam),
AdminNote: normalizedRemark(in.SubmitParam),
BindRemark: in.BatchNo,
SkipBindInsert: true,
Operator: in.Operator,
})
if err != nil {
return nil, err
}
sort.Strings(sourceBatchNos)
if err = updateBatchMainAfterComplete(
ctx,
tx,
in,
subscription.OrderSn,
subscription.Customer,
subscription.Package,
subscription.Amount,
subscription.PackageBeginAt,
subscription.PackageEndAt,
sourceBatchNos,
subscription.PackageStatus,
subscription.EffectiveStatus,
payment,
); err != nil {
return nil, err
}
now := subscription.SubscribeTime
for _, row := range in.Rows {
stock := stockByIccid[row.Iccid]
bind := subscription.BindsByIccid[row.Iccid]
if bind == nil {
return nil, gerror.Newf("ICCID订购绑定生成失败:%s", row.Iccid)
}
cardType := inboundCardTypeValue(inboundBatchInfos[stock.BatchNo])
cardRows = append(cardRows, do.TSimCardInfo{
Iccid: stock.Iccid,
Msisdn: stock.Msisdn,
Imsi: stock.Imsi,
Imei: stock.Imei,
DeptId: in.SubmitParam.CustomerAgentId,
CurrentPackageCode: subscription.Package.PackageCode,
PackageStatus: subscription.PackageStatus,
PackageTotalFlowMb: packageTotalFlowMB(subscription.Package),
BossCardStatus: consts.V3ApiSimBossCardStatusPendingActivate,
CardType: cardType,
InboundBatchNo: stock.BatchNo,
OutboundBatchNo: in.BatchNo,
OutStockTime: now,
FlowPkgBeginAt: subscription.PackageBeginAt,
FlowPkgEndAt: subscription.PackageEndAt,
ChanKey: stock.ChanKey,
TenantId: in.Operator.TenantId,
CreatedDept: in.Operator.DeptId,
CreatedBy: in.Operator.UserId,
CreatedAt: now,
UpdatedBy: in.Operator.UserId,
UpdatedAt: now,
})
detailUpdates[row.Iccid] = &gjsonJsonHolder{Data: buildDetailSnapshot(row, stock, cardType, subscription.Customer, subscription.Package, in, subscription.OrderSn, bind.SubscriptionNo, subscription.Amount, subscription.PackageBeginAt, subscription.PackageEndAt)}
}
for start := 0; start < len(cardRows); start += outboundDatabaseInsertChunkSize {
end := start + outboundDatabaseInsertChunkSize
if end > len(cardRows) {
end = len(cardRows)
}
if _, err = tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).Data(cardRows[start:end]).Insert(); err != nil {
return nil, gerror.Wrap(err, "新增正式卡失败")
}
}
if err = packageSvc.InsertCardPackageBinds(ctx, tx, subscription.BindRows); err != nil {
return nil, err
}
if err = updateBatchDetailSnapshots(ctx, tx, in.BatchNo, detailUpdates, in.Operator.UserId); err != nil {
return nil, err
}
var affected int64
for start := 0; start < len(iccids); start += outboundDatabaseChunkSize {
end := start + outboundDatabaseChunkSize
if end > len(iccids) {
end = len(iccids)
}
result, deleteErr := tx.Ctx(ctx).Model(dao.TSimCardStock.Table()).
WhereIn(dao.TSimCardStock.Columns().Iccid, iccids[start:end]).
Where(dao.TSimCardStock.Columns().CreatedDept, in.Operator.DeptId).
Delete()
if deleteErr != nil {
return nil, gerror.Wrap(deleteErr, "删除库存卡失败")
}
chunkAffected, rowsErr := result.RowsAffected()
if rowsErr != nil {
return nil, gerror.Wrap(rowsErr, "读取库存删除数量失败")
}
affected += chunkAffected
}
if int(affected) != quantity {
return nil, gerror.New("部分库存卡已变化,请刷新后重试")
}
return &CompleteOutput{
OrderId: subscription.OrderId,
OrderSn: subscription.OrderSn,
BatchNo: in.BatchNo,
BatchType: consts.BatchTypeOutbound,
Quantity: quantity,
SourceBatchNos: sourceBatchNos,
Customer: subscription.Customer,
Package: subscription.Package,
PackageBeginAt: subscription.PackageBeginAt,
PackageEndAt: subscription.PackageEndAt,
PackageStatus: subscription.PackageStatus,
EffectiveStatus: subscription.EffectiveStatus,
}, nil
}
type gjsonJsonHolder struct {
Data any
}
func validateSubmitParam(in *model.TSimCardOutboundSubmitParam) error {
normalizeSubmitParam(in)
if in.CustomerAgentId <= 0 {
return gerror.New("客户不能为空")
}
if strings.TrimSpace(in.PackageCode) == "" {
return gerror.New("套餐不能为空")
}
if in.PackageBeginTime == nil {
return gerror.New("套餐开始时间不能为空")
}
if in.DurationMonths <= 0 {
return gerror.New("套餐时长必须大于0")
}
if in.DurationMonthPart < 0 || in.DurationMonthPart > 11 {
return gerror.New("套餐购买月份必须在0到11之间")
}
if in.GiftDurationMonthPart < 0 || in.GiftDurationMonthPart > 11 {
return gerror.New("赠送月份必须在0到11之间")
}
if in.GiftDurationMonths < 0 {
return gerror.New("赠送时长不能小于0")
}
amount, err := parseOutboundAmount(in.Amount)
if err != nil {
return err
}
if amount <= 0 {
return gerror.New("订单金额必须大于0")
}
return nil
}
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 strictRowsToIccids(rows []*Row) ([]string, error) {
seen := make(map[string]int, len(rows))
iccids := make([]string, 0, len(rows))
for _, row := range rows {
if row == nil {
return nil, gerror.New("出库行不能为空")
}
iccid := strings.TrimSpace(row.Iccid)
if iccid == "" {
return nil, gerror.Newf("第%d行ICCID为空,不能出库", row.RowNo)
}
if firstRowNo, ok := seen[iccid]; ok {
return nil, gerror.Newf("第%d行ICCID与第%d行重复:%s", row.RowNo, firstRowNo, iccid)
}
seen[iccid] = row.RowNo
row.Iccid = iccid
iccids = append(iccids, iccid)
}
return iccids, nil
}
func updateBatchMainAfterComplete(
ctx context.Context,
tx gdb.TX,
in *CompleteInput,
orderSn string,
customer *entity.SysDept,
pkg *entity.TSimFlowPackageInfo,
amount float64,
beginAt, endAt *gtime.Time,
sourceBatchNos []string,
packageStatus, effectiveStatus int,
payment *outboundPaymentSnapshot,
) error {
current := &struct {
BatchInfo *gjson.Json
}{}
if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Fields(dao.BatchMain.Columns().BatchInfo).
Where(dao.BatchMain.Columns().BatchNo, in.BatchNo).
Scan(current); err != nil {
return gerror.Wrap(err, "读取出库批次信息失败")
}
info := g.Map{}
if current.BatchInfo != nil {
for key, value := range current.BatchInfo.Map() {
info[key] = value
}
}
completedInfo := g.Map{
"orderSn": orderSn,
"customerAgentId": in.SubmitParam.CustomerAgentId,
"customerName": customer.DeptName,
"packageCode": pkg.PackageCode,
"packageName": pkg.PackageName,
"packageType": pkg.PackageType,
"packageBeginTime": formatTime(beginAt),
"packageEndTime": formatTime(endAt),
"durationYears": in.SubmitParam.DurationYears,
"durationMonthPart": in.SubmitParam.DurationMonthPart,
"durationMonths": in.SubmitParam.DurationMonths,
"giftDurationYears": in.SubmitParam.GiftDurationYears,
"giftDurationMonthPart": in.SubmitParam.GiftDurationMonthPart,
"giftDurationMonths": in.SubmitParam.GiftDurationMonths,
"durationLabel": displayDurationLabel(in.SubmitParam.DurationYears, in.SubmitParam.DurationMonthPart, in.SubmitParam.DurationMonths),
"giftDurationLabel": displayDurationLabel(in.SubmitParam.GiftDurationYears, in.SubmitParam.GiftDurationMonthPart, in.SubmitParam.GiftDurationMonths),
"amount": amount,
"quantity": len(in.Rows),
"offlineContractCompleted": in.SubmitParam.OfflineContractCompleted,
"remark": normalizedRemark(in.SubmitParam),
"buyerNote": normalizedRemark(in.SubmitParam),
"adminNote": normalizedRemark(in.SubmitParam),
"sourceBatchNos": sourceBatchNos,
"packageStatus": packageStatus,
"effectiveStatus": effectiveStatus,
}
for key, value := range completedInfo {
info[key] = value
}
if payment != nil {
info["paymentMode"] = payment.PaymentMode
info["payType"] = payment.PayType
info["payStatus"] = payment.PayStatus
info["balanceAmount"] = payment.BalanceAmount
info["offlinePaidAmount"] = payment.OfflinePaidAmount
info["balanceBefore"] = payment.BalanceBefore
info["balanceAfter"] = payment.BalanceAfter
info["balanceDetailSerialNo"] = payment.BalanceDetailSerialNo
info["payerDeptId"] = payment.PayerDeptId
}
_, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, in.BatchNo).
Data(do.BatchMain{
OrderSn: orderSn,
TargetDeptId: in.SubmitParam.CustomerAgentId,
BatchInfo: gjson.New(info),
UpdatedBy: in.Operator.UserId,
UpdatedAt: gtime.Now(),
}).
Update()
if err != nil {
return gerror.Wrap(err, "更新出库批次失败")
}
return nil
}
func updateBatchDetailSnapshots(ctx context.Context, tx gdb.TX, batchNo string, snapshots map[string]*gjsonJsonHolder, userId int64) error {
if len(snapshots) == 0 {
return nil
}
iccids := make([]string, 0, len(snapshots))
for iccid := range snapshots {
iccids = append(iccids, iccid)
}
sort.Strings(iccids)
columns := dao.BatchDetail.Columns()
for start := 0; start < len(iccids); start += outboundDetailUpdateChunkSize {
end := start + outboundDetailUpdateChunkSize
if end > len(iccids) {
end = len(iccids)
}
chunk := iccids[start:end]
var sqlBuilder strings.Builder
args := make([]any, 0, len(chunk)*3+2)
sqlBuilder.WriteString("UPDATE `")
sqlBuilder.WriteString(dao.BatchDetail.Table())
sqlBuilder.WriteString("` SET `")
sqlBuilder.WriteString(columns.Snapshot)
sqlBuilder.WriteString("` = CASE `")
sqlBuilder.WriteString(columns.ItemId)
sqlBuilder.WriteString("`")
for _, iccid := range chunk {
sqlBuilder.WriteString(" WHEN ? THEN ?")
args = append(args, iccid, gjson.New(snapshots[iccid].Data).String())
}
sqlBuilder.WriteString(" ELSE `")
sqlBuilder.WriteString(columns.Snapshot)
sqlBuilder.WriteString("` END, `")
sqlBuilder.WriteString(columns.UpdatedBy)
sqlBuilder.WriteString("` = ? WHERE `")
sqlBuilder.WriteString(columns.BatchNo)
sqlBuilder.WriteString("` = ? AND `")
sqlBuilder.WriteString(columns.ItemId)
sqlBuilder.WriteString("` IN (")
args = append(args, userId, batchNo)
for index, iccid := range chunk {
if index > 0 {
sqlBuilder.WriteByte(',')
}
sqlBuilder.WriteByte('?')
args = append(args, iccid)
}
sqlBuilder.WriteByte(')')
if _, err := tx.Exec(sqlBuilder.String(), args...); err != nil {
return gerror.Wrapf(err, "更新批次明细快照失败,范围:%d-%d", start+1, end)
}
}
return nil
}
func buildDetailSnapshot(row *Row, stock *entity.TSimCardStock, cardType any, customer *entity.SysDept, pkg *entity.TSimFlowPackageInfo, in *CompleteInput, orderSn, subscriptionNo string, amount float64, beginAt, endAt *gtime.Time) g.Map {
return g.Map{
"ICCID": stock.Iccid,
"MSISDN": stock.Msisdn,
"IMSI": stock.Imsi,
"IMEI": stock.Imei,
"cardType": cardType,
"inboundBatchNo": stock.BatchNo,
"outboundBatchNo": in.BatchNo,
"orderSn": orderSn,
"subscriptionNo": subscriptionNo,
"customerAgentId": in.SubmitParam.CustomerAgentId,
"customerName": customer.DeptName,
"packageCode": pkg.PackageCode,
"packageName": pkg.PackageName,
"packageType": pkg.PackageType,
"packageBeginTime": formatTime(beginAt),
"packageEndTime": formatTime(endAt),
"durationYears": in.SubmitParam.DurationYears,
"durationMonthPart": in.SubmitParam.DurationMonthPart,
"durationMonths": in.SubmitParam.DurationMonths,
"giftDurationYears": in.SubmitParam.GiftDurationYears,
"giftDurationMonthPart": in.SubmitParam.GiftDurationMonthPart,
"giftDurationMonths": in.SubmitParam.GiftDurationMonths,
"durationLabel": displayDurationLabel(in.SubmitParam.DurationYears, in.SubmitParam.DurationMonthPart, in.SubmitParam.DurationMonths),
"giftDurationLabel": displayDurationLabel(in.SubmitParam.GiftDurationYears, in.SubmitParam.GiftDurationMonthPart, in.SubmitParam.GiftDurationMonths),
"packageTotalFlowMb": packageTotalFlowMB(pkg),
"amount": amount,
"remark": normalizedRemark(in.SubmitParam),
"rowNo": row.RowNo,
"stock": g.Map{
"chanKey": stock.ChanKey,
"sourceInboundBatchNo": stock.BatchNo,
},
}
}
func inboundCardTypeValue(info *gjson.Json) any {
if info == nil {
return nil
}
value := strings.TrimSpace(info.Get("inboundOptions.cardType").String())
if value == "" {
value = strings.TrimSpace(info.Get("cardType").String())
}
if value == "" {
return nil
}
cardType, err := strconv.Atoi(value)
if err != nil || cardType <= 0 {
return nil
}
return cardType
}
func formatTime(t *gtime.Time) string {
if t == nil {
return ""
}
return t.Format("Y-m-d H:i:s")
}
func chooseString(primary, fallback string) string {
primary = strings.TrimSpace(primary)
if primary != "" {
return primary
}
return strings.TrimSpace(fallback)
}
type outboundPaymentSnapshot struct {
PaymentMode string
PayType int
PayStatus int
PayAmount float64
BalanceAmount float64
OfflinePaidAmount float64
BalanceBefore float64
BalanceAfter float64
BalanceDetailSerialNo string
PaymentTime *gtime.Time
PayerDeptId int64
}
func prepareOutboundPayment(ctx context.Context, tx gdb.TX, batchNo string, amount float64, operator batchimport.OperatorContext, in *model.TSimCardOutboundSubmitParam) (*outboundPaymentSnapshot, error) {
if in == nil {
return nil, gerror.New("出库付款参数不能为空")
}
if amount < 0 {
return nil, gerror.New("订单金额不能小于0")
}
now := gtime.Now()
if in.OfflineContractCompleted {
return &outboundPaymentSnapshot{
PaymentMode: outboundPaymentModeOffline,
PayType: consts.PayTypeOffline,
PayStatus: consts.PayStatusPaid,
PayAmount: amount,
OfflinePaidAmount: amount,
PaymentTime: now,
PayerDeptId: in.CustomerAgentId,
}, nil
}
return deductOutboundBalanceTx(ctx, tx, batchNo, amount, now, operator, in.CustomerAgentId)
}
func deductOutboundBalanceTx(ctx context.Context, tx gdb.TX, batchNo string, amount float64, paymentTime *gtime.Time, operator batchimport.OperatorContext, payerDeptID int64) (*outboundPaymentSnapshot, error) {
if tx == nil {
return nil, gerror.New("出库余额支付必须在事务中执行")
}
if operator.DeptId <= 0 || operator.TenantId == "" || operator.UserId <= 0 {
return nil, gerror.New("当前用户未绑定组织、租户或用户信息")
}
if payerDeptID <= 0 {
return nil, gerror.New("出库客户不能为空")
}
if paymentTime == nil {
paymentTime = gtime.Now()
}
serialNo := outboundBalanceSerialNo(batchNo)
if serialNo == "" {
return nil, gerror.New("出库余额流水号生成失败")
}
account := &entity.TAgentBalance{}
if err := tx.Ctx(ctx).Model(dao.TAgentBalance.Table()).
Where(dao.TAgentBalance.Columns().DeptId, payerDeptID).
Where(dao.TAgentBalance.Columns().TenantId, operator.TenantId).
LockUpdate().
Scan(account); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, gerror.Wrap(err, "查询账户余额失败")
}
if account.Id == 0 {
return nil, gerror.New("客户暂无余额账户,请先充值")
}
if account.Status != outboundFinanceAccountStatusNormal {
return nil, gerror.New("账户余额已冻结,不能扣余额")
}
record, err := tx.Ctx(ctx).Model(dao.TBalanceDetail.Table()).
Where(dao.TBalanceDetail.Columns().TenantId, operator.TenantId).
Where(dao.TBalanceDetail.Columns().SerialNo, serialNo).
LockUpdate().
One()
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, gerror.Wrap(err, "查询出库余额流水失败")
}
if record != nil && !record.IsEmpty() {
return nil, gerror.New("当前出库批次已扣款,请勿重复提交")
}
before := roundOutboundAmount(account.Balance)
if before+0.0001 < amount {
return nil, gerror.Newf("账户余额不足,需支付%.2f元,当前余额%.2f元", amount, before)
}
after := roundOutboundAmount(before - amount)
now := gtime.Now()
result, err := tx.Ctx(ctx).Model(dao.TAgentBalance.Table()).
WherePri(account.Id).
Data(g.Map{
dao.TAgentBalance.Columns().Balance: after,
dao.TAgentBalance.Columns().UpdatedBy: operator.UserId,
dao.TAgentBalance.Columns().UpdatedAt: now,
}).
Update()
if err != nil {
return nil, gerror.Wrap(err, "扣减账户余额失败")
}
affected, err := result.RowsAffected()
if err != nil {
return nil, gerror.Wrap(err, "读取账户余额更新结果失败")
}
if affected != 1 {
return nil, gerror.New("账户余额已变化,请重试")
}
if _, err = tx.Ctx(ctx).Model(dao.TBalanceDetail.Table()).Data(g.Map{
dao.TBalanceDetail.Columns().DeptId: payerDeptID,
dao.TBalanceDetail.Columns().SerialNo: serialNo,
dao.TBalanceDetail.Columns().TransactionTime: paymentTime,
dao.TBalanceDetail.Columns().IncomeExpenseType: outboundFinanceIncomeExpenseExpense,
dao.TBalanceDetail.Columns().TransactionType: outboundFinanceTransactionTypeConsume,
dao.TBalanceDetail.Columns().TransactionChannel: outboundFinanceTransactionChannelBalance,
dao.TBalanceDetail.Columns().TransactionStatus: outboundFinanceTransactionStatusPaid,
dao.TBalanceDetail.Columns().Amount: amount,
dao.TBalanceDetail.Columns().BeforeBalance: before,
dao.TBalanceDetail.Columns().BalanceAfter: after,
dao.TBalanceDetail.Columns().OrderSn: batchNo,
dao.TBalanceDetail.Columns().Remark: fmt.Sprintf("SIM卡出库余额支付,批次号:%s", batchNo),
dao.TBalanceDetail.Columns().TenantId: operator.TenantId,
dao.TBalanceDetail.Columns().CreatedDept: operator.DeptId,
dao.TBalanceDetail.Columns().CreatedBy: operator.UserId,
dao.TBalanceDetail.Columns().CreatedAt: now,
}).Insert(); err != nil {
return nil, gerror.Wrap(err, "写入出库余额流水失败")
}
return &outboundPaymentSnapshot{
PaymentMode: outboundPaymentModeBalance,
PayType: consts.PayTypeBalance,
PayStatus: consts.PayStatusPaid,
PayAmount: amount,
BalanceAmount: amount,
BalanceBefore: before,
BalanceAfter: after,
BalanceDetailSerialNo: serialNo,
PaymentTime: paymentTime,
PayerDeptId: payerDeptID,
}, nil
}
func subscriptionAmount(in *model.TSimCardOutboundSubmitParam) float64 {
amount, _ := parseOutboundAmount(in.Amount)
return amount
}
func parseOutboundAmount(value string) (float64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, gerror.New("订单金额不能为空")
}
amount, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, gerror.New("订单金额格式不正确")
}
if amount < 0 {
return 0, gerror.New("订单金额不能小于0")
}
return roundOutboundAmount(amount), nil
}
func roundOutboundAmount(value float64) float64 {
return math.Round(value*100) / 100
}
func outboundBalanceSerialNo(batchNo string) string {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return ""
}
return "OUTPAY" + batchNo
}
func displayDurationValue(years, months int) int {
if years > 0 {
return years
}
if months > 0 && months%12 == 0 {
return months / 12
}
return months
}
func displayDurationLabel(years, monthPart, totalMonths int) string {
if years <= 0 && monthPart <= 0 && totalMonths > 0 {
years = totalMonths / 12
monthPart = totalMonths % 12
}
parts := make([]string, 0, 2)
if years > 0 {
parts = append(parts, fmt.Sprintf("%d年", years))
}
if monthPart > 0 {
parts = append(parts, fmt.Sprintf("%d个月", monthPart))
}
if len(parts) == 0 && totalMonths > 0 {
parts = append(parts, fmt.Sprintf("%d个月", totalMonths))
}
return strings.Join(parts, "")
}
func packageTotalFlowMB(pkg *entity.TSimFlowPackageInfo) float64 {
if pkg == nil {
return 0
}
if pkg.FlowMb > 0 {
return pkg.FlowMb
}
if pkg.FlowGb > 0 {
return pkg.FlowGb * 1024
}
return 0
}
@@ -0,0 +1,17 @@
package outbound
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common/cardsource"
)
func ParseInputRows(ctx context.Context, input string) ([]batchimport.Row, error) {
return cardsource.BuildIccidRows(ctx, input, "批量输入出库数据为空", func(line cardsource.IccidLine) *Row {
return &Row{
RowNo: line.RowNo,
Iccid: line.Iccid,
}
})
}
@@ -0,0 +1,344 @@
package outbound
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"github.com/gogf/gf/v2/os/gtime"
)
type ExcelParseResult struct {
Rows []*Row
SubmitParam *model.TSimCardOutboundSubmitParam
}
type consistencyValue struct {
rowNo int
normalized string
}
func excelHeaders() []string {
return []string{
"ICCID",
"MSISDN",
"IMSI",
"客户/机构",
"套餐",
"套餐开始时间",
"购买时长(年)",
"赠送时长(月)",
"订单金额",
"线下合同/销售/走账已完成",
"备注",
}
}
func excelRequiredHeaders() []string {
return []string{"ICCID"}
}
func ParseExcelFile(ctx context.Context, filePath string) (*ExcelParseResult, error) {
parsedRows, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{
RequiredHeaders: excelRequiredHeaders(),
SingleSheet: true,
})
if err != nil {
return nil, err
}
return buildExcelParseResult(parsedRows), nil
}
func ParseExcelReader(ctx context.Context, reader io.Reader) (*ExcelParseResult, error) {
parsedRows, err := excel.ParseReader(ctx, reader, excel.ParseOptions{
RequiredHeaders: excelRequiredHeaders(),
SingleSheet: true,
})
if err != nil {
return nil, err
}
return buildExcelParseResult(parsedRows), nil
}
func buildExcelParseResult(parsedRows []excel.ParsedRow) *ExcelParseResult {
rows := make([]*Row, 0, len(parsedRows))
consistentCustomerAgentId := consistencyValue{}
consistentPackageCode := consistencyValue{}
consistentPackageBeginTime := consistencyValue{}
consistentDurationYears := consistencyValue{}
consistentDurationMonthPart := consistencyValue{}
consistentGiftDurationYears := consistencyValue{}
consistentGiftDurationMonthPart := consistencyValue{}
consistentAmount := consistencyValue{}
consistentOfflineContractCompleted := consistencyValue{}
consistentRemark := consistencyValue{}
for _, parsed := range parsedRows {
row := &Row{
RowNo: parsed.RowNo,
Iccid: excel.Field(parsed, "ICCID"),
Msisdn: excel.Field(parsed, "MSISDN"),
Imsi: excel.Field(parsed, "IMSI"),
// 出库不再配置测试期、沉默期;1 表示否。
HasTestPeriod: 1,
HasSilentPeriod: 1,
}
parseRequiredPositiveInt(row, excelField(parsed, "customerAgentId"), "客户/机构", func(value int) {
row.CustomerAgentId = int64(value)
checkConsistency(row, "客户/机构", strconv.FormatInt(row.CustomerAgentId, 10), &consistentCustomerAgentId)
})
row.PackageCode = strings.TrimSpace(excelField(parsed, "packageCode"))
if row.PackageCode == "" {
row.AddReason("套餐不能为空")
} else {
checkConsistency(row, "套餐", row.PackageCode, &consistentPackageCode)
}
parseRequiredDateTime(row, excelField(parsed, "packageBeginTime"), "套餐开始时间", func(value *gtime.Time) {
row.PackageBeginTime = value
checkConsistency(row, "套餐开始时间", formatTime(value), &consistentPackageBeginTime)
})
parseExcelDuration(row, parsed, &consistentDurationYears, &consistentDurationMonthPart)
parseExcelGiftDuration(row, parsed, &consistentGiftDurationYears, &consistentGiftDurationMonthPart)
parseRequiredAmount(row, excelField(parsed, "amount"), func(value string) {
row.Amount = value
checkConsistency(row, "订单金额", value, &consistentAmount)
})
parseRequiredBool(row, excelField(parsed, "offlineContractCompleted"), "线下合同/销售/走账已完成", func(value bool) {
row.OfflineContractCompleted = value
checkConsistency(row, "线下合同/销售/走账已完成", strconv.FormatBool(value), &consistentOfflineContractCompleted)
})
row.Remark = strings.TrimSpace(excelField(parsed, "remark"))
checkConsistency(row, "备注", row.Remark, &consistentRemark)
rows = append(rows, row)
}
result := &ExcelParseResult{Rows: rows}
if hasInvalidRows(rows) {
return result
}
result.SubmitParam = &model.TSimCardOutboundSubmitParam{
CustomerAgentId: rows[0].CustomerAgentId,
PackageCode: rows[0].PackageCode,
PackageBeginTime: rows[0].PackageBeginTime,
DurationYears: rows[0].DurationYears,
DurationMonthPart: rows[0].DurationMonthPart,
DurationMonths: rows[0].DurationMonths,
GiftDurationYears: rows[0].GiftDurationYears,
GiftDurationMonthPart: rows[0].GiftDurationMonthPart,
GiftDurationMonths: rows[0].GiftDurationMonths,
HasTestPeriod: 1,
HasSilentPeriod: 1,
Amount: rows[0].Amount,
OfflineContractCompleted: rows[0].OfflineContractCompleted,
Remark: rows[0].Remark,
BuyerNote: rows[0].Remark,
AdminNote: rows[0].Remark,
}
return result
}
func excelField(row excel.ParsedRow, name string) string {
for _, alias := range excelFieldAliases(name) {
if value := strings.TrimSpace(excel.Field(row, alias)); value != "" {
return value
}
}
return ""
}
func excelFieldAliases(name string) []string {
switch name {
case "customerAgentId":
return []string{"客户/机构", "客户/机构ID", "客户ID", "customerAgentId"}
case "packageCode":
return []string{"套餐", "套餐编码", "packageCode"}
case "packageBeginTime":
return []string{"套餐开始时间", "packageBeginTime"}
case "durationYears":
return []string{"购买时长(年)", "套餐购买时长(年)", "durationYears"}
case "durationMonthPart":
return []string{"购买时长(月)", "套餐购买时长(月)", "durationMonthPart"}
case "durationMonths":
return []string{"durationMonths"}
case "giftDurationYears":
return []string{"赠送时长(年)", "giftDurationYears"}
case "giftDurationMonthPart":
return []string{"赠送时长(月)", "giftDurationMonthPart"}
case "giftDurationMonths":
return []string{"giftDurationMonths"}
case "amount":
return []string{"订单金额", "amount"}
case "offlineContractCompleted":
return []string{"线下合同/销售/走账已完成", "是否走线下", "offlineContractCompleted"}
case "remark":
return []string{"备注", "remark", "buyerNote", "adminNote"}
default:
return []string{name}
}
}
func parseExcelDuration(row *Row, parsed excel.ParsedRow, consistentYears, consistentMonthPart *consistencyValue) {
yearsRaw := excelField(parsed, "durationYears")
monthPartRaw := excelField(parsed, "durationMonthPart")
if strings.TrimSpace(yearsRaw) != "" || strings.TrimSpace(monthPartRaw) != "" {
parseOptionalNonNegativeInt(row, yearsRaw, "购买时长(年)", 0, func(value int) {
row.DurationYears = value
checkConsistency(row, "购买时长(年)", strconv.Itoa(value), consistentYears)
})
parseOptionalMonthPart(row, monthPartRaw, "购买时长(月)", func(value int) {
row.DurationMonthPart = value
checkConsistency(row, "购买时长(月)", strconv.Itoa(value), consistentMonthPart)
})
row.DurationMonths = row.DurationYears*12 + row.DurationMonthPart
if row.DurationMonths <= 0 {
row.AddReason("购买时长必须大于0")
}
return
}
parseRequiredPositiveInt(row, excelField(parsed, "durationMonths"), "套餐购买时长(月)", func(value int) {
row.DurationMonths = value
row.DurationYears = value / 12
row.DurationMonthPart = value % 12
checkConsistency(row, "套餐购买时长(月)", strconv.Itoa(value), consistentMonthPart)
})
}
func parseExcelGiftDuration(row *Row, parsed excel.ParsedRow, consistentYears, consistentMonthPart *consistencyValue) {
yearsRaw := excelField(parsed, "giftDurationYears")
monthPartRaw := excelField(parsed, "giftDurationMonthPart")
if strings.TrimSpace(yearsRaw) != "" || strings.TrimSpace(monthPartRaw) != "" {
parseOptionalNonNegativeInt(row, yearsRaw, "赠送时长(年)", 0, func(value int) {
row.GiftDurationYears = value
checkConsistency(row, "赠送时长(年)", strconv.Itoa(value), consistentYears)
})
parseOptionalMonthPart(row, monthPartRaw, "赠送时长(月)", func(value int) {
row.GiftDurationMonthPart = value
checkConsistency(row, "赠送时长(月)", strconv.Itoa(value), consistentMonthPart)
})
row.GiftDurationMonths = row.GiftDurationYears*12 + row.GiftDurationMonthPart
return
}
parseOptionalNonNegativeInt(row, excelField(parsed, "giftDurationMonths"), "赠送时长(月)", 0, func(value int) {
row.GiftDurationMonths = value
row.GiftDurationYears = value / 12
row.GiftDurationMonthPart = value % 12
checkConsistency(row, "赠送时长(月)", strconv.Itoa(value), consistentMonthPart)
})
}
func checkConsistency(row *Row, field, normalized string, base *consistencyValue) {
if row == nil || !row.Valid() {
return
}
if base.rowNo == 0 {
base.rowNo = row.RowNo
base.normalized = normalized
return
}
if base.normalized != normalized {
row.AddReason(fmt.Sprintf("%s与第%d行不一致", field, base.rowNo))
}
}
func parseRequiredPositiveInt(row *Row, raw, field string, apply func(value int)) {
raw = strings.TrimSpace(raw)
if raw == "" {
row.AddReason(field + "不能为空")
return
}
value, err := excel.ParsePositiveInt(raw)
if err != nil {
row.AddReason(field + "格式不正确")
return
}
apply(value)
}
func parseOptionalNonNegativeInt(row *Row, raw, field string, defaultValue int, apply func(value int)) {
raw = strings.TrimSpace(raw)
if raw == "" {
apply(defaultValue)
return
}
value, err := excel.ParseNonNegativeInt(raw)
if err != nil {
row.AddReason(field + "格式不正确")
return
}
apply(value)
}
func parseOptionalMonthPart(row *Row, raw, field string, apply func(value int)) {
parseOptionalNonNegativeInt(row, raw, field, 0, func(value int) {
if value > 11 {
row.AddReason(field + "必须在0到11之间")
return
}
apply(value)
})
}
func parseOptionalAmount(row *Row, raw string, apply func(value string)) {
raw = strings.TrimSpace(raw)
if raw == "" {
apply("")
return
}
value, err := excel.ParseAmount(raw)
if err != nil {
row.AddReason("订单金额格式不正确")
return
}
apply(fmt.Sprintf("%.2f", value))
}
func parseRequiredAmount(row *Row, raw string, apply func(value string)) {
raw = strings.TrimSpace(raw)
if raw == "" {
row.AddReason("订单金额不能为空")
return
}
parseOptionalAmount(row, raw, apply)
}
func parseRequiredBool(row *Row, raw, field string, apply func(value bool)) {
raw = strings.TrimSpace(raw)
if raw == "" {
row.AddReason(field + "不能为空")
return
}
value, err := excel.ParseBool(raw)
if err != nil {
row.AddReason(field + "格式不正确")
return
}
apply(value)
}
func parseRequiredDateTime(row *Row, raw, field string, apply func(value *gtime.Time)) {
raw = strings.TrimSpace(raw)
if raw == "" {
row.AddReason(field + "不能为空")
return
}
value, err := excel.ParseDateTime(raw)
if err != nil {
row.AddReason(field + "格式不正确")
return
}
apply(value)
}
@@ -0,0 +1,178 @@
package outbound
import (
"context"
"fmt"
"strings"
)
func validateRows(ctx context.Context, rows []*Row, opts PreviewOptions, repo *Repository) error {
identityFirstRow := make(map[string]*Row, len(rows))
iccids := make([]string, 0, len(rows))
msisdns := make([]string, 0, len(rows))
imsis := make([]string, 0, len(rows))
for _, row := range rows {
row.Iccid = strings.TrimSpace(row.Iccid)
row.Msisdn = strings.TrimSpace(row.Msisdn)
row.Imsi = strings.TrimSpace(row.Imsi)
row.RangeCodeType = rowIdentityCodeType(row)
key := rowIdentityKey(row)
if key == "" {
row.AddReason(rowIdentityLabel(row.RangeCodeType) + "不能为空")
continue
}
identityKey := row.RangeCodeType + ":" + key
if firstRow, ok := identityFirstRow[identityKey]; ok {
label := rowIdentityLabel(row.RangeCodeType)
row.AddReason(fmt.Sprintf("%s与第%d行重复", label, firstRow.RowNo))
firstRow.AddReason(fmt.Sprintf("%s与第%d行重复", label, row.RowNo))
continue
}
identityFirstRow[identityKey] = row
switch row.RangeCodeType {
case "msisdn":
msisdns = append(msisdns, key)
case "imsi":
imsis = append(imsis, key)
default:
iccids = append(iccids, key)
}
}
stockByIccid, err := repo.GetStockCardsByIccids(ctx, iccids)
if err != nil {
return err
}
stockByMsisdn, err := repo.GetStockCardsByMsisdns(ctx, msisdns)
if err != nil {
return err
}
stockByImsi, err := repo.GetStockCardsByImsis(ctx, imsis)
if err != nil {
return err
}
rowStock := make(map[*Row]*StockCard, len(rows))
resolvedIccidFirstRow := make(map[string]*Row, len(rows))
resolvedIccids := make([]string, 0, len(rows))
for _, row := range rows {
if !row.Valid() {
continue
}
stock := stockForRow(row, stockByIccid, stockByMsisdn, stockByImsi)
if stock == nil {
row.AddReason(rowIdentityLabel(row.RangeCodeType) + "不存在库存记录")
continue
}
providedIccid := row.Iccid
providedMsisdn := row.Msisdn
providedImsi := row.Imsi
row.Iccid = strings.TrimSpace(stock.Iccid)
row.Msisdn = chooseString(row.Msisdn, stock.Msisdn)
row.Imsi = chooseString(row.Imsi, stock.Imsi)
if row.Iccid == "" {
row.AddReason("库存卡ICCID为空")
continue
}
if firstRow, ok := resolvedIccidFirstRow[row.Iccid]; ok {
row.AddReason(fmt.Sprintf("ICCID与第%d行指向同一库存卡", firstRow.RowNo))
firstRow.AddReason(fmt.Sprintf("ICCID与第%d行指向同一库存卡", row.RowNo))
continue
}
resolvedIccidFirstRow[row.Iccid] = row
if stock.CreatedDept != opts.DeptId {
row.AddReason("ICCID不属于当前机构库存")
continue
}
if opts.StrictStockIdentity {
if providedIccid != "" && providedIccid != strings.TrimSpace(stock.Iccid) {
row.AddReason("ICCID与库存记录不一致")
continue
}
if providedMsisdn != "" && providedMsisdn != strings.TrimSpace(stock.Msisdn) {
row.AddReason("MSISDN与库存记录不一致")
continue
}
if providedImsi != "" && providedImsi != strings.TrimSpace(stock.Imsi) {
row.AddReason("IMSI与库存记录不一致")
continue
}
}
row.SourceBatchNo = strings.TrimSpace(stock.BatchNo)
rowStock[row] = stock
resolvedIccids = append(resolvedIccids, row.Iccid)
}
formalByIccid, err := repo.GetExistingFormalIccids(ctx, resolvedIccids)
if err != nil {
return err
}
for _, row := range rows {
if !row.Valid() || rowStock[row] == nil {
continue
}
if formalByIccid[row.Iccid] {
row.AddReason("ICCID已存在正式卡记录")
}
}
return nil
}
func rowIdentityCodeType(row *Row) string {
if row == nil {
return "iccid"
}
if codeType := normalizeRangeCodeType(row.RangeCodeType); codeType != "" {
return codeType
}
switch {
case strings.TrimSpace(row.Iccid) != "":
return "iccid"
case strings.TrimSpace(row.Msisdn) != "":
return "msisdn"
case strings.TrimSpace(row.Imsi) != "":
return "imsi"
default:
return "iccid"
}
}
func rowIdentityKey(row *Row) string {
if row == nil {
return ""
}
switch row.RangeCodeType {
case "msisdn":
return strings.TrimSpace(row.Msisdn)
case "imsi":
return strings.TrimSpace(row.Imsi)
default:
return strings.TrimSpace(row.Iccid)
}
}
func rowIdentityLabel(codeType string) string {
switch codeType {
case "msisdn":
return "MSISDN"
case "imsi":
return "IMSI"
default:
return "ICCID"
}
}
func stockForRow(row *Row, stockByIccid, stockByMsisdn, stockByImsi map[string]*StockCard) *StockCard {
if row == nil {
return nil
}
switch row.RangeCodeType {
case "msisdn":
return stockByMsisdn[strings.TrimSpace(row.Msisdn)]
case "imsi":
return stockByImsi[strings.TrimSpace(row.Imsi)]
default:
return stockByIccid[strings.TrimSpace(row.Iccid)]
}
}
@@ -0,0 +1,401 @@
package outbound
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/library/contexts"
"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"
)
type Processor struct {
repo *Repository
opts PreviewOptions
config batchimport.ProcessorConfig
excelSubmitParam *model.TSimCardOutboundSubmitParam
}
func (p *Processor) ExecutePending(ctx context.Context, tx gdb.TX, batchNo string, in *model.TSimCardOutboundSubmitParam, rows []*Row, operator batchimport.OperatorContext, requiredSourceBatchNo string) (*CompleteOutput, error) {
if strings.TrimSpace(batchNo) == "" {
return nil, gerror.New("出库批次号不能为空")
}
if in == nil {
return nil, gerror.New("出库参数不能为空")
}
if err := p.validateExecuteContext(ctx, tx, in, operator, rows, requiredSourceBatchNo); err != nil {
return nil, err
}
return p.Complete(ctx, tx, &CompleteInput{
SubmitParam: in,
BatchNo: batchNo,
Rows: rows,
Operator: operator,
StrictStockIdentity: p.opts.StrictStockIdentity,
RequiredSourceBatchNo: requiredSourceBatchNo,
})
}
const (
outboundCardPackageType = 101
outboundPoolPackageType = 102
)
func NewProcessor(opts PreviewOptions) *Processor {
return &Processor{
repo: NewRepository(),
opts: opts,
config: batchimport.ProcessorConfig{
Code: "sim_card_outbound",
BatchType: consts.BatchTypeOutbound,
BatchNoPrefix: consts.BatchNoPrefixOutbound,
TaskName: consts.ResourceBatchDownloadTaskSimCardOutbound,
ExportScene: consts.ResourceExportSceneOutbound,
ImportDir: consts.SimCardOutboundSubDirImportExcel,
ResultDir: consts.SimCardOutboundSubDirResultExcel,
MaxFileSize: consts.SimCardOutboundMaxFileSize,
TemplateHeaders: excelHeaders(),
RequiredHeaders: excelRequiredHeaders(),
},
}
}
func (p *Processor) Config() batchimport.ProcessorConfig {
return p.config
}
func (p *Processor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
parsed, err := ParseExcelFile(ctx, filePath)
if err != nil {
return nil, err
}
p.excelSubmitParam = nil
if parsed != nil {
p.excelSubmitParam = parsed.SubmitParam
}
result := make([]batchimport.Row, 0, len(parsed.Rows))
for _, row := range parsed.Rows {
result = append(result, row)
}
return result, nil
}
func (p *Processor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
outboundRows := make([]*Row, 0, len(rows))
for _, item := range rows {
row, ok := item.(*Row)
if !ok {
item.AddErrorReason("导入行类型错误")
continue
}
outboundRows = append(outboundRows, row)
}
opts := p.opts
if opts.DeptId == 0 {
opts.DeptId = contexts.GetDeptId(ctx)
}
return validateRows(ctx, outboundRows, opts, p.repo)
}
func (p *Processor) Execute(ctx context.Context, tx gdb.TX, req *batchimport.SubmitRequest, rows []batchimport.Row) error {
if req == nil {
return gerror.New("出库请求不能为空")
}
in := p.submitParams(req.BizParams)
if in == nil {
return gerror.New("出库参数类型错误")
}
outboundRows := make([]*Row, 0, len(rows))
for _, item := range rows {
row, ok := item.(*Row)
if !ok {
return gerror.New("导入行类型错误")
}
outboundRows = append(outboundRows, row)
}
if err := p.validateExecuteContext(ctx, tx, in, req.Operator, outboundRows, requiredSourceBatchNo(req.BizParams)); err != nil {
return err
}
output, err := p.Complete(ctx, tx, &CompleteInput{
SubmitParam: in,
BatchNo: req.BatchNo,
Rows: outboundRows,
Operator: req.Operator,
StrictStockIdentity: p.opts.StrictStockIdentity,
RequiredSourceBatchNo: requiredSourceBatchNo(req.BizParams),
})
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{}
info["strictStockIdentity"] = p.opts.StrictStockIdentity
if result != nil && result.ImportFileOssId > 0 {
info["importFileOssId"] = result.ImportFileOssId
} else if importOss != nil {
info["importFileOssId"] = importOss.OssId
}
if resultOss != nil {
info["resultFileOssId"] = resultOss.OssId
}
if downloadId > 0 {
info["resultDownloadId"] = downloadId
}
if in := p.submitParams(req.BizParams); in != nil {
normalizeSubmitParam(in)
info["customerAgentId"] = in.CustomerAgentId
info["packageCode"] = strings.TrimSpace(in.PackageCode)
info["packageBeginTime"] = in.PackageBeginTime
info["durationYears"] = in.DurationYears
info["durationMonthPart"] = in.DurationMonthPart
info["durationMonths"] = in.DurationMonths
info["giftDurationYears"] = in.GiftDurationYears
info["giftDurationMonthPart"] = in.GiftDurationMonthPart
info["giftDurationMonths"] = in.GiftDurationMonths
info["amount"] = strings.TrimSpace(in.Amount)
info["offlineContractCompleted"] = in.OfflineContractCompleted
info["remark"] = normalizedRemark(in)
info["buyerNote"] = normalizedRemark(in)
info["adminNote"] = normalizedRemark(in)
}
if in, ok := req.BizParams.(*model.TSimCardOutboundRangeSubmitParam); ok && in != nil {
info["rangeCodeType"] = normalizeRangeCodeType(in.RangeCodeType)
info["startIccid"] = strings.TrimSpace(in.StartIccid)
info["endIccid"] = strings.TrimSpace(in.EndIccid)
info["startMsisdn"] = strings.TrimSpace(in.StartMsisdn)
info["endMsisdn"] = strings.TrimSpace(in.EndMsisdn)
info["startImsi"] = strings.TrimSpace(in.StartImsi)
info["endImsi"] = strings.TrimSpace(in.EndImsi)
}
if in, ok := req.BizParams.(*model.TSimCardOutboundBatchSubmitParam); ok && in != nil {
info["inboundBatchNo"] = strings.TrimSpace(in.BatchNo)
}
if output, ok := req.ExecutionResult.(*CompleteOutput); ok && output != nil {
info["orderSn"] = output.OrderSn
info["quantity"] = output.Quantity
info["sourceBatchNos"] = output.SourceBatchNos
info["packageBeginTime"] = output.PackageBeginAt
info["packageEndTime"] = output.PackageEndAt
info["packageStatus"] = output.PackageStatus
info["effectiveStatus"] = output.EffectiveStatus
if output.Customer != nil {
info["customerName"] = output.Customer.DeptName
}
if output.Package != nil {
info["packageName"] = output.Package.PackageName
info["packageType"] = output.Package.PackageType
}
}
return gjson.New(info)
}
func (p *Processor) validateExecuteContext(ctx context.Context, tx gdb.TX, in *model.TSimCardOutboundSubmitParam, operator batchimport.OperatorContext, rows []*Row, requiredSourceBatchNo string) error {
normalizeSubmitParam(in)
if err := validateSubmitParam(in); err != nil {
return err
}
if err := p.validateSelectablePackage(ctx, tx, in.PackageCode, operator.TenantId); err != nil {
return err
}
iccids, err := strictRowsToIccids(rows)
if err != nil {
return err
}
stockByIccid, err := p.repo.LockStockCardsByIccids(ctx, tx, iccids)
if err != nil {
return gerror.Wrap(err, "校验出库库存卡失败")
}
formalByIccid, err := p.repo.GetExistingFormalIccids(ctx, iccids)
if err != nil {
return gerror.Wrap(err, "校验正式卡失败")
}
for _, row := range rows {
if row == nil || row.Reason != "" {
continue
}
stock := stockByIccid[row.Iccid]
reason := validateOutboundRow(row, stock, formalByIccid[row.Iccid], operator, p.opts.StrictStockIdentity, requiredSourceBatchNo)
if reason != "" {
row.AddReason(reason)
}
}
if hasInvalidRows(rows) {
markRemainingRowsSkipped(rows)
return gerror.New("存在不满足出库条件的卡,本次未执行")
}
return nil
}
func (p *Processor) validateSelectablePackage(ctx context.Context, tx gdb.TX, packageCode, tenantId string) error {
pkg, err := p.repo.LoadPackage(ctx, tx, tenantId, packageCode)
if err != nil {
return gerror.Wrap(err, "校验出库套餐失败")
}
if pkg == nil {
return gerror.New("出库套餐不存在")
}
if !isOutboundSelectablePackageType(pkg.PackageType) {
return gerror.New("出库套餐必须是卡套餐或池套餐")
}
if pkg.Enabled != 1 {
return gerror.New("出库套餐未启用,不能出库")
}
return nil
}
func isOutboundSelectablePackageType(packageType int) bool {
return packageType == outboundCardPackageType || packageType == outboundPoolPackageType
}
func normalizeSubmitParam(in *model.TSimCardOutboundSubmitParam) {
if in == nil {
return
}
if in.DurationMonths <= 0 && in.DurationYears > 0 {
in.DurationMonths = in.DurationYears*12 + in.DurationMonthPart
}
if in.DurationMonths <= 0 && in.DurationMonthPart > 0 {
in.DurationMonths = in.DurationMonthPart
}
if in.DurationYears <= 0 && in.DurationMonthPart <= 0 && in.DurationMonths > 0 {
in.DurationYears = in.DurationMonths / 12
in.DurationMonthPart = in.DurationMonths % 12
}
if in.GiftDurationMonths <= 0 && in.GiftDurationYears > 0 {
in.GiftDurationMonths = in.GiftDurationYears*12 + in.GiftDurationMonthPart
}
if in.GiftDurationMonths <= 0 && in.GiftDurationMonthPart > 0 {
in.GiftDurationMonths = in.GiftDurationMonthPart
}
if in.GiftDurationYears <= 0 && in.GiftDurationMonthPart <= 0 && in.GiftDurationMonths > 0 {
in.GiftDurationYears = in.GiftDurationMonths / 12
in.GiftDurationMonthPart = in.GiftDurationMonths % 12
}
disablePeriodOptions(in)
if remark := normalizedRemark(in); remark != "" {
in.Remark = remark
in.BuyerNote = remark
in.AdminNote = remark
}
}
func disablePeriodOptions(in *model.TSimCardOutboundSubmitParam) {
if in == nil {
return
}
// 1 表示否。出库不再暴露测试期、沉默期配置,统一按无测试期/无沉默期保存。
in.HasTestPeriod = 1
in.TestPeriodDays = 0
in.TestPeriodLatestExpiryDate = nil
in.HasSilentPeriod = 1
in.SilentPeriodDays = 0
in.SilentPeriodLatestExpiryDate = nil
}
func normalizedRemark(in *model.TSimCardOutboundSubmitParam) string {
if in == nil {
return ""
}
if value := strings.TrimSpace(in.Remark); value != "" {
return value
}
if value := strings.TrimSpace(in.BuyerNote); value != "" {
return value
}
return strings.TrimSpace(in.AdminNote)
}
func (p *Processor) submitParams(params any) *model.TSimCardOutboundSubmitParam {
if in := outboundSubmitParams(params); in != nil {
return in
}
return p.excelSubmitParam
}
func validateOutboundRow(row *Row, stock *entity.TSimCardStock, formalExists bool, operator batchimport.OperatorContext, strictStockIdentity bool, requiredSourceBatchNo string) string {
if stock == nil {
return "ICCID不存在库存记录,不能出库"
}
if stock.CreatedDept != operator.DeptId {
return "ICCID不属于当前机构库存,不能出库"
}
if requiredSourceBatchNo != "" && strings.TrimSpace(stock.BatchNo) != requiredSourceBatchNo {
return "ICCID不属于指定库存批次,不能出库"
}
if formalExists {
return "ICCID已存在正式卡记录,不能出库"
}
if strictStockIdentity {
if strings.TrimSpace(row.Msisdn) != strings.TrimSpace(stock.Msisdn) {
return "MSISDN与库存记录不一致,不能出库"
}
if strings.TrimSpace(row.Imsi) != strings.TrimSpace(stock.Imsi) {
return "IMSI与库存记录不一致,不能出库"
}
}
return ""
}
func hasInvalidRows(rows []*Row) bool {
for _, row := range rows {
if row != nil && row.Reason != "" {
return true
}
}
return false
}
func markRemainingRowsSkipped(rows []*Row) {
for _, row := range rows {
if row != nil && row.Reason == "" {
row.AddReason("同批存在不满足出库条件的卡,本行未执行")
}
}
}
func outboundSubmitParams(params any) *model.TSimCardOutboundSubmitParam {
switch in := params.(type) {
case *model.TSimCardOutboundSubmitParam:
return in
case *model.TSimCardOutboundRangeSubmitParam:
if in == nil {
return nil
}
return &in.TSimCardOutboundSubmitParam
case *model.TSimCardOutboundInputSubmitParam:
if in == nil {
return nil
}
return &in.TSimCardOutboundSubmitParam
case *model.TSimCardOutboundBatchSubmitParam:
if in == nil {
return nil
}
return &in.TSimCardOutboundSubmitParam
default:
return nil
}
}
func requiredSourceBatchNo(params any) string {
if in, ok := params.(*model.TSimCardOutboundBatchSubmitParam); ok && in != nil {
return strings.TrimSpace(in.BatchNo)
}
return ""
}
@@ -0,0 +1,20 @@
package outbound
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/cmd/inithttp"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
"github.com/gogf/gf/v2/frame/g"
)
func init() {
inithttp.RegisterHttpInitFunc("outboundBatchQueue", func(ctx context.Context) error {
if !rabbitmq.Enabled(ctx) {
g.Log().Warning(ctx, "outbound batch queue is disabled because RabbitMQ is not configured")
return nil
}
return ConsumeAsync(ctx)
})
}
@@ -0,0 +1,126 @@
package outbound
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/numrange"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common/cardsource"
"github.com/gogf/gf/v2/errors/gerror"
)
const maxRangeRows = 100000
func BuildRangeRows(ctx context.Context, rangeCodeType, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi string) ([]batchimport.Row, error) {
if isLegacyFullIdentityRange(rangeCodeType, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi) {
return buildFullIdentityRangeRows(ctx, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi)
}
codeType, start, end, err := selectedRange(ctx, rangeCodeType, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi)
if err != nil {
return nil, err
}
values, err := numrange.Build(ctx, start, end, strings.ToUpper(codeType), maxRangeRows)
if err != nil {
return nil, err
}
if len(values) == 0 {
return nil, gerror.New("连号出库数据为空")
}
rows := make([]batchimport.Row, 0, len(values))
for i, value := range values {
row := &Row{RowNo: i + 1, RangeCodeType: codeType}
switch codeType {
case "msisdn":
row.Msisdn = value
case "imsi":
row.Imsi = value
default:
row.Iccid = value
}
rows = append(rows, row)
}
return rows, nil
}
func buildFullIdentityRangeRows(ctx context.Context, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi string) ([]batchimport.Row, error) {
return cardsource.BuildRangeRows(ctx, cardsource.RangeConfig{
StartIccid: startIccid,
EndIccid: endIccid,
StartMsisdn: startMsisdn,
EndMsisdn: endMsisdn,
StartImsi: startImsi,
EndImsi: endImsi,
MaxRows: maxRangeRows,
EmptyMessage: "连号出库数据为空",
OverLimitMessage: "连号出库数量不能超过%d条",
CountMismatchMessage: "ICCID号段数量(%d)、MSISDN号段数量(%d)、IMSI号段数量(%d)必须一致",
}, func(rowNo int, iccid, msisdn, imsi string) *Row {
return &Row{
RowNo: rowNo,
Iccid: iccid,
Msisdn: msisdn,
Imsi: imsi,
}
})
}
func isLegacyFullIdentityRange(rangeCodeType, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi string) bool {
return strings.TrimSpace(rangeCodeType) == "" &&
strings.TrimSpace(startIccid) != "" && strings.TrimSpace(endIccid) != "" &&
strings.TrimSpace(startMsisdn) != "" && strings.TrimSpace(endMsisdn) != "" &&
strings.TrimSpace(startImsi) != "" && strings.TrimSpace(endImsi) != ""
}
func selectedRange(ctx context.Context, rangeCodeType, startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi string) (codeType, start, end string, err error) {
_ = ctx
codeType = normalizeRangeCodeType(rangeCodeType)
if codeType == "" {
codeType = inferRangeCodeType(startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi)
}
switch codeType {
case "iccid":
start, end = strings.TrimSpace(startIccid), strings.TrimSpace(endIccid)
case "msisdn":
start, end = strings.TrimSpace(startMsisdn), strings.TrimSpace(endMsisdn)
case "imsi":
start, end = strings.TrimSpace(startImsi), strings.TrimSpace(endImsi)
default:
return "", "", "", gerror.New("请选择连号号码类型")
}
if start == "" || end == "" {
return "", "", "", gerror.Newf("%s起止号不能为空", strings.ToUpper(codeType))
}
return codeType, start, end, nil
}
func normalizeRangeCodeType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "iccid", "iccids":
return "iccid"
case "msisdn", "msisdns":
return "msisdn"
case "imsi", "imsis":
return "imsi"
default:
return ""
}
}
func inferRangeCodeType(startIccid, endIccid, startMsisdn, endMsisdn, startImsi, endImsi string) string {
complete := func(start, end string) bool {
return strings.TrimSpace(start) != "" && strings.TrimSpace(end) != ""
}
switch {
case complete(startIccid, endIccid):
return "iccid"
case complete(startMsisdn, endMsisdn):
return "msisdn"
case complete(startImsi, endImsi):
return "imsi"
default:
return ""
}
}
@@ -0,0 +1,204 @@
package outbound
import (
"context"
"strings"
"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/encoding/gjson"
)
const (
outboundDatabaseChunkSize = 5000
outboundDatabaseInsertChunkSize = 500
)
type Repository struct{}
type StockCard struct {
Iccid string `orm:"iccid"`
Msisdn string `orm:"msisdn"`
Imsi string `orm:"imsi"`
Imei string `orm:"imei"`
ChanKey string `orm:"chan_key"`
CreatedDept int64 `orm:"created_dept"`
BatchNo string `orm:"batch_no"`
}
func NewRepository() *Repository {
return &Repository{}
}
func (r *Repository) GetStockCardsByIccids(ctx context.Context, iccids []string) (map[string]*StockCard, error) {
columns := dao.TSimCardStock.Columns()
return r.getStockCardsByColumn(ctx, columns.Iccid, iccids)
}
func (r *Repository) GetStockCardsByMsisdns(ctx context.Context, msisdns []string) (map[string]*StockCard, error) {
columns := dao.TSimCardStock.Columns()
return r.getStockCardsByColumn(ctx, columns.Msisdn, msisdns)
}
func (r *Repository) GetStockCardsByImsis(ctx context.Context, imsis []string) (map[string]*StockCard, error) {
columns := dao.TSimCardStock.Columns()
return r.getStockCardsByColumn(ctx, columns.Imsi, imsis)
}
func (r *Repository) getStockCardsByColumn(ctx context.Context, column string, values []string) (map[string]*StockCard, error) {
result := make(map[string]*StockCard)
uniqueValues := uniqueStrings(values)
if len(uniqueValues) == 0 {
return result, nil
}
columns := dao.TSimCardStock.Columns()
for start := 0; start < len(uniqueValues); start += outboundDatabaseChunkSize {
end := start + outboundDatabaseChunkSize
if end > len(uniqueValues) {
end = len(uniqueValues)
}
var list []*StockCard
if err := dao.TSimCardStock.Ctx(ctx).
Fields(columns.Iccid, columns.Msisdn, columns.Imsi, columns.Imei, columns.ChanKey, columns.CreatedDept, columns.BatchNo).
WhereIn(column, uniqueValues[start:end]).
Scan(&list); err != nil {
return nil, err
}
for _, item := range list {
if item == nil {
continue
}
key := ""
switch column {
case columns.Msisdn:
key = item.Msisdn
case columns.Imsi:
key = item.Imsi
default:
key = item.Iccid
}
if key != "" {
result[key] = item
}
}
}
return result, nil
}
func (r *Repository) LockStockCardsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*entity.TSimCardStock, error) {
result := make(map[string]*entity.TSimCardStock)
uniqueIccids := uniqueStrings(iccids)
if len(uniqueIccids) == 0 {
return result, nil
}
for start := 0; start < len(uniqueIccids); start += outboundDatabaseChunkSize {
end := start + outboundDatabaseChunkSize
if end > len(uniqueIccids) {
end = len(uniqueIccids)
}
var list []*entity.TSimCardStock
if err := tx.Ctx(ctx).Model(dao.TSimCardStock.Table()).
WhereIn(dao.TSimCardStock.Columns().Iccid, uniqueIccids[start:end]).
LockUpdate().
Scan(&list); err != nil {
return nil, err
}
for _, item := range list {
if item != nil && item.Iccid != "" {
result[item.Iccid] = item
}
}
}
return result, nil
}
func (r *Repository) GetExistingFormalIccids(ctx context.Context, iccids []string) (map[string]bool, error) {
result := make(map[string]bool)
uniqueIccids := uniqueStrings(iccids)
if len(uniqueIccids) == 0 {
return result, nil
}
columns := dao.TSimCardInfo.Columns()
for start := 0; start < len(uniqueIccids); start += outboundDatabaseChunkSize {
end := start + outboundDatabaseChunkSize
if end > len(uniqueIccids) {
end = len(uniqueIccids)
}
var list []struct {
Iccid string `orm:"iccid"`
}
if err := dao.TSimCardInfo.Ctx(ctx).
Fields(columns.Iccid).
WhereIn(columns.Iccid, uniqueIccids[start:end]).
Scan(&list); err != nil {
return nil, err
}
for _, item := range list {
if item.Iccid != "" {
result[item.Iccid] = true
}
}
}
return result, nil
}
func (r *Repository) GetInboundBatchInfos(ctx context.Context, tx gdb.TX, batchNos []string) (map[string]*gjson.Json, error) {
result := make(map[string]*gjson.Json)
uniqueBatchNos := uniqueStrings(batchNos)
if len(uniqueBatchNos) == 0 {
return result, nil
}
var list []struct {
BatchNo string `orm:"batch_no"`
BatchInfo *gjson.Json `orm:"batch_info"`
}
dbModel := dao.BatchMain.Ctx(ctx)
if tx != nil {
dbModel = tx.Ctx(ctx).Model(dao.BatchMain.Table()).Safe()
}
columns := dao.BatchMain.Columns()
if err := dbModel.
Fields(columns.BatchNo, columns.BatchInfo).
WhereIn(columns.BatchNo, uniqueBatchNos).
Scan(&list); err != nil {
return nil, err
}
for _, item := range list {
if item.BatchNo != "" {
result[item.BatchNo] = item.BatchInfo
}
}
return result, nil
}
func (r *Repository) LoadPackage(ctx context.Context, tx gdb.TX, tenantId, packageCode string) (*entity.TSimFlowPackageInfo, error) {
packageCode = strings.TrimSpace(packageCode)
if packageCode == "" {
return nil, nil
}
mod := dao.TSimFlowPackageInfo.Ctx(ctx)
if tx != nil {
mod = tx.Ctx(ctx).Model(dao.TSimFlowPackageInfo.Table()).Safe()
}
mod = mod.Where(dao.TSimFlowPackageInfo.Columns().PackageCode, packageCode)
if strings.TrimSpace(tenantId) != "" {
mod = mod.Where(dao.TSimFlowPackageInfo.Columns().TenantId, strings.TrimSpace(tenantId))
}
var pkg *entity.TSimFlowPackageInfo
if err := mod.Scan(&pkg); err != nil {
return nil, err
}
return pkg, nil
}
@@ -0,0 +1,129 @@
package outbound
import (
"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/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
type Row struct {
RowNo int
Iccid string
Msisdn string
Imsi string
RangeCodeType string
CustomerAgentId int64
PackageCode string
PackageBeginTime *gtime.Time
DurationYears int
DurationMonthPart int
DurationMonths int
GiftDurationYears int
GiftDurationMonthPart int
GiftDurationMonths int
HasTestPeriod int
TestPeriodDays int
TestPeriodLatestExpiryDate *gtime.Time
HasSilentPeriod int
SilentPeriodDays int
SilentPeriodLatestExpiryDate *gtime.Time
Amount string
OfflineContractCompleted bool
Remark string
SourceBatchNo 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,
"rangeCodeType": r.RangeCodeType,
"customerAgentId": r.CustomerAgentId,
"packageCode": r.PackageCode,
"packageBeginTime": formatTime(r.PackageBeginTime),
"durationYears": r.DurationYears,
"durationMonthPart": r.DurationMonthPart,
"durationMonths": r.DurationMonths,
"giftDurationYears": r.GiftDurationYears,
"giftDurationMonthPart": r.GiftDurationMonthPart,
"giftDurationMonths": r.GiftDurationMonths,
"amount": r.Amount,
"offlineContractCompleted": r.OfflineContractCompleted,
"remark": r.Remark,
"batchNo": r.SourceBatchNo,
})
}
type PreviewOptions struct {
DeptId int64
StrictStockIdentity bool
}
type CompleteInput struct {
SubmitParam *model.TSimCardOutboundSubmitParam
BatchNo string
Rows []*Row
Operator batchimport.OperatorContext
StrictStockIdentity bool
RequiredSourceBatchNo string
}
type CompleteOutput struct {
OrderId int64
OrderSn string
BatchNo string
BatchType int
Quantity int
SourceBatchNos []string
Customer *entity.SysDept
Package *entity.TSimFlowPackageInfo
PackageBeginAt *gtime.Time
PackageEndAt *gtime.Time
PackageStatus int
EffectiveStatus int
}
@@ -0,0 +1,27 @@
package outbound
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
}