Files
iotlcm/internal/service/addpackage/executor.go
T
MiFaZhan 2bec257289 chore: 初始开源发布
iotlcm-worker-svr:异步执行中枢(RabbitMQ 消费者 + 全量同步编排)。

- Go module:iotlcm-worker-svr → git.5iot.com/iotlcm-public/iotlcm-worker-svr(214 文件)
- 凭据与内部标识脱敏:MySQL/Redis 凭据、私网与生产 IP、内网域名、
  库名 xiujie_admin → iotlcm、哨兵名占位
- 159 处真实手机号 → 合法 11 位编造号(保留号段与后 4 位,维持互异,
  不破坏「校验 MSISDN 格式」等测试断言)
- 移除内部资产:CI 触发器、Jenkins 触发测试文档、预演门禁与镜像
  digest 记录
- Dockerfile 改用公共基础镜像(golang:1.25-alpine → alpine:3.23),
  移除写死的 git 凭据
- README 重写(架构/全量同步模型/配置/已知失败),补 Apache-2.0
  LICENSE,LF 行尾 + .gitattributes

已知:3 个测试包在源仓同一提交上同样失败(client/upstream、
service/allocate、service/inbound),属既有缺陷,本仓未引入亦未修复。
2026-10-22 16:37:56 +08:00

1429 lines
45 KiB
Go

package addpackage
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
renewchangeservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/renewchange"
)
type executor struct {
repo *renewchangeservice.Repository
batchType int
poolMode bool
nowFunc NowFunc
}
type flowPoolRow struct {
Id int64 `orm:"id"`
FlowPoolCode string `orm:"flow_pool_code"`
DeptId int64 `orm:"dept_id"`
PoolPackageCode int64 `orm:"pool_package_code"`
AddOnFlowMb float64 `orm:"add_on_flow_mb"`
TotalFlowMb float64 `orm:"total_flow_mb"`
RemainFlowMb float64 `orm:"remain_flow_mb"`
PoolStatus int `orm:"pool_status"`
TenantId string `orm:"tenant_id"`
CreatedDept int64 `orm:"created_dept"`
CreatedBy int64 `orm:"created_by"`
UpdatedBy int64 `orm:"updated_by"`
UpdatedAt *gtime.Time `orm:"updated_at"`
}
type sysDeptRow struct {
DeptId int64 `orm:"dept_id"`
ParentId int64 `orm:"parent_id"`
TenantId string `orm:"tenant_id"`
DeletedAt *gtime.Time `orm:"deleted_at"`
}
type agentBalanceRow struct {
Id int64 `orm:"id"`
DeptId int64 `orm:"dept_id"`
Balance float64 `orm:"balance"`
Status int `orm:"status"`
TenantId string `orm:"tenant_id"`
}
type balanceDetailRow struct {
DeptId int64 `orm:"dept_id"`
SerialNo string `orm:"serial_no"`
TransactionTime *gtime.Time `orm:"transaction_time"`
IncomeExpenseType int `orm:"income_expense_type"`
TransactionType int `orm:"transaction_type"`
TransactionChannel int `orm:"transaction_channel"`
TransactionStatus int `orm:"transaction_status"`
Amount float64 `orm:"amount"`
BeforeBalance float64 `orm:"before_balance"`
BalanceAfter float64 `orm:"balance_after"`
ChannelSerialNo string `orm:"channel_serial_no"`
OrderSn string `orm:"order_sn"`
Remark string `orm:"remark"`
TenantId string `orm:"tenant_id"`
CreatedDept int64 `orm:"created_dept"`
CreatedBy int64 `orm:"created_by"`
CreatedAt *gtime.Time `orm:"created_at"`
}
const (
addPackageBizType = 3
addPackageTransactionType = 1
addPackageTransactionChan = 3
addPackageTransactionPaid = 1
addPackageAccountStatusOK = 0
)
const (
addPackageSameBaseFailureReason = "同一批加油包目标当前基础套餐必须一致"
addPackageSamePackageFailureReason = "同一批加油包套餐必须一致"
addPackageSameEffectiveFailureReason = "同一批加油包生效方式必须一致"
addPackageSamePriceFailureReason = "同一批加油包单价必须一致"
)
func NewCardExecutor(repo *renewchangeservice.Repository) batch.Executor {
if repo == nil {
repo = renewchangeservice.NewRepository()
}
return &executor{repo: repo, batchType: consts.BatchTypeCardAddPackage, nowFunc: timeNow}
}
func NewPoolExecutor(repo *renewchangeservice.Repository) batch.Executor {
if repo == nil {
repo = renewchangeservice.NewRepository()
}
return &executor{repo: repo, batchType: consts.BatchTypePoolAddPackage, poolMode: true, nowFunc: timeNow}
}
func (e *executor) Execute(ctx context.Context, batchNo string) error {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return gerror.New("add package batch number is empty")
}
main, err := e.repo.LoadProcessingBatch(ctx, batchNo, e.batchType)
if err != nil {
return err
}
details, err := e.repo.LoadRunningDetails(ctx, batchNo, e.batchType)
if err != nil {
return gerror.Wrap(err, "load add package details failed")
}
task := &batchTask{
main: main,
total: len(details),
plans: make([]*detailPlan, 0, len(details)),
paymentIntent: readPaymentIntent(main.BatchInfo),
paymentSnapshot: make(map[int64]*PaymentSnapshot),
}
if len(details) == 0 {
return e.finalizeFailure(ctx, task, "加油包批次没有可处理明细")
}
if err := e.buildTask(ctx, task, details); err != nil {
return e.finalizeFailure(ctx, task, err.Error())
}
if task.valid == 0 {
if task.reason == "" {
task.reason = "加油包批次没有可处理明细"
}
return e.finalizeFailure(ctx, task, task.reason)
}
orderSN, err := e.applyBusiness(ctx, task)
if err != nil {
return e.finalizeFailure(ctx, task, err.Error())
}
if err = e.finalizeSuccess(ctx, task, orderSN); err != nil {
return err
}
return nil
}
func (e *executor) buildTask(ctx context.Context, task *batchTask, details []*rc.BatchDetailRow) error {
seen := make(map[string]struct{}, len(details))
now := e.nowTime()
for _, detail := range details {
if detail == nil {
continue
}
plan := &detailPlan{detail: detail}
snapshot := readDetailSnapshot(detail.Snapshot)
plan.targetCode = strings.TrimSpace(detail.ItemId)
if snapshot != nil {
if v := strings.TrimSpace(snapshot.TargetCode); v != "" {
plan.targetCode = v
}
plan.packageCode = strings.TrimSpace(snapshot.PackageCode)
plan.remark = strings.TrimSpace(snapshot.Remark)
}
if plan.packageCode == "" && task.main.BatchInfo != nil {
plan.packageCode = strings.TrimSpace(task.main.BatchInfo.Get("packageCode").String())
}
mode, modeErr := effectiveModeFrom(task.main.BatchInfo, snapshot)
if modeErr != nil {
plan.reason = modeErr.Error()
task.plans = append(task.plans, plan)
continue
}
plan.effectiveMode = mode
if plan.targetCode == "" {
plan.reason = "目标编码不能为空"
task.plans = append(task.plans, plan)
continue
}
hasPackageCode := plan.packageCode != ""
if !hasPackageCode && !task.prepareOnly {
plan.reason = "加油包套餐编码不能为空"
task.plans = append(task.plans, plan)
continue
}
dupKey := strings.Join([]string{plan.targetCode, plan.packageCode}, "|")
if _, ok := seen[dupKey]; ok {
plan.reason = "批次内重复的目标编码 + 套餐编码"
task.plans = append(task.plans, plan)
continue
}
seen[dupKey] = struct{}{}
if e.poolMode {
pool, err := e.loadPool(ctx, task.main.TenantId, plan.targetCode)
if err != nil {
plan.reason = err.Error()
task.plans = append(task.plans, plan)
continue
}
if pool == nil || pool.Id == 0 {
plan.reason = "流量池不存在"
task.plans = append(task.plans, plan)
continue
}
plan.pool = pool
plan.customerDeptID = pool.DeptId
plan.basePackageKey = fmt.Sprintf("%d", pool.PoolPackageCode)
if pool.PoolStatus != 0 {
plan.reason = "流量池状态异常,不能订购加油包"
task.plans = append(task.plans, plan)
continue
}
if err := e.ensureNoActiveTopup(ctx, nil, task.main.TenantId, plan.targetCode, now); err != nil {
plan.reason = err.Error()
task.plans = append(task.plans, plan)
continue
}
if !hasPackageCode {
task.valid++
task.plans = append(task.plans, plan)
continue
}
pkg, price, err := e.resolvePrice(ctx, task.main.TenantId, pool.DeptId, plan.packageCode)
if err != nil {
plan.reason = err.Error()
task.plans = append(task.plans, plan)
continue
}
if pkg.PackageType != 104 {
plan.reason = "目标套餐类型不是流量池加油包"
task.plans = append(task.plans, plan)
continue
}
if pkg.PoolPackageCode != pool.PoolPackageCode {
plan.reason = "加油包不属于当前流量池套餐"
task.plans = append(task.plans, plan)
continue
}
plan.price = price
plan.unitAmount = price.UnitPrice
plan.amount = roundAmount(price.UnitPrice)
plan.effectiveTime, plan.expireTime, plan.effectiveNow, err = calcMonthWindow(plan.effectiveMode, now)
if err != nil {
plan.reason = err.Error()
task.plans = append(task.plans, plan)
continue
}
task.valid++
task.plans = append(task.plans, plan)
continue
}
cardMap, err := e.repo.Tables().LoadCardsByIccids(ctx, []string{plan.targetCode})
if err != nil {
plan.reason = err.Error()
task.plans = append(task.plans, plan)
continue
}
card := cardMap[plan.targetCode]
if card == nil || card.Id == 0 {
plan.reason = "ICCID不存在"
task.plans = append(task.plans, plan)
continue
}
plan.card = card
plan.customerDeptID = card.DeptId
plan.basePackageKey = strings.TrimSpace(card.CurrentPackageCode)
if card.PackageStatus != consts.PackageStatusEffective {
plan.reason = "当前卡套餐未生效,不能订购加油包"
task.plans = append(task.plans, plan)
continue
}
if err := e.ensureNoActiveTopup(ctx, nil, task.main.TenantId, plan.targetCode, now); err != nil {
plan.reason = err.Error()
task.plans = append(task.plans, plan)
continue
}
basePkg, err := e.repo.Tables().LoadPackageByCode(ctx, task.main.TenantId, card.CurrentPackageCode)
if err != nil || basePkg == nil {
plan.reason = "当前卡基础套餐不存在"
task.plans = append(task.plans, plan)
continue
}
if !containsString(packageCodesFromJSON(basePkg.TopupPackageCodes), plan.packageCode) {
if hasPackageCode {
plan.reason = "当前套餐不允许订购该加油包"
task.plans = append(task.plans, plan)
continue
}
}
if !hasPackageCode {
task.valid++
task.plans = append(task.plans, plan)
continue
}
pkg, price, err := e.resolvePrice(ctx, task.main.TenantId, card.DeptId, plan.packageCode)
if err != nil {
plan.reason = err.Error()
task.plans = append(task.plans, plan)
continue
}
if pkg.PackageType != 103 {
plan.reason = "目标套餐类型不是单卡加油包"
task.plans = append(task.plans, plan)
continue
}
plan.price = price
plan.unitAmount = price.UnitPrice
plan.amount = roundAmount(price.UnitPrice)
plan.effectiveTime, plan.expireTime, plan.effectiveNow, err = calcMonthWindow(plan.effectiveMode, now)
if err != nil {
plan.reason = err.Error()
task.plans = append(task.plans, plan)
continue
}
task.valid++
task.plans = append(task.plans, plan)
}
enforceAddPackageBatchUniformity(task)
task.valid = 0
task.success = 0
task.fail = 0
task.reason = ""
for _, plan := range task.plans {
if plan == nil {
continue
}
if plan.reason != "" {
task.fail++
if task.reason == "" {
task.reason = plan.reason
}
continue
}
task.success++
task.valid++
}
if task.reason == "" && task.valid == 0 {
task.reason = "加油包批次没有可处理明细"
}
return nil
}
func enforceAddPackageBatchUniformity(task *batchTask) {
if task == nil {
return
}
var first *detailPlan
for _, plan := range task.plans {
if plan == nil || plan.reason != "" {
continue
}
if first == nil {
first = plan
continue
}
switch {
case strings.TrimSpace(plan.basePackageKey) != strings.TrimSpace(first.basePackageKey):
plan.reason = addPackageSameBaseFailureReason
case strings.TrimSpace(plan.packageCode) != strings.TrimSpace(first.packageCode):
plan.reason = addPackageSamePackageFailureReason
case strings.TrimSpace(plan.effectiveMode) != strings.TrimSpace(first.effectiveMode):
plan.reason = addPackageSameEffectiveFailureReason
case !sameAddPackageAmount(plan.unitAmount, first.unitAmount):
plan.reason = addPackageSamePriceFailureReason
}
}
}
func sameAddPackageAmount(left, right float64) bool {
return amountCents(left) == amountCents(right)
}
func amountCents(value float64) int64 {
if value < 0 {
value = 0
}
return int64(value*100 + 0.5)
}
func (e *executor) applyBusiness(ctx context.Context, task *batchTask) (string, error) {
now := e.nowTime()
tenantID := task.main.TenantId
userID := task.main.CreatedBy
deptID := task.main.CreatedDept
isTopDept, err := e.isTopDept(ctx, tenantID, deptID)
if err != nil {
return "", err
}
intent, err := normalizePaymentIntent(task.paymentIntent, isTopDept)
if err != nil {
return "", err
}
task.paymentIntent = intent
if orderSN, recovered, err := e.recoverAppliedBusiness(ctx, task); err != nil {
return "", err
} else if recovered {
return orderSN, nil
}
planGroups := make(map[int64][]*detailPlan)
orderedDeptIDs := make([]int64, 0, len(task.plans))
for _, plan := range task.plans {
if plan == nil || plan.reason != "" {
continue
}
payerDeptID := plan.customerDeptID
if payerDeptID <= 0 {
return "", gerror.New("加油包归属部门不能为空")
}
if _, ok := planGroups[payerDeptID]; !ok {
orderedDeptIDs = append(orderedDeptIDs, payerDeptID)
}
planGroups[payerDeptID] = append(planGroups[payerDeptID], plan)
}
if len(planGroups) == 0 {
return "", gerror.New("加油包订单金额不能为空")
}
sort.Slice(orderedDeptIDs, func(i, j int) bool { return orderedDeptIDs[i] < orderedDeptIDs[j] })
orderSNs := make([]string, 0, len(orderedDeptIDs))
if err := e.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, plan := range task.plans {
if plan == nil || plan.reason != "" {
continue
}
if err := e.ensureNoActiveTopup(ctx, tx, tenantID, plan.targetCode, now); err != nil {
return err
}
}
for _, payerDeptID := range orderedDeptIDs {
groupPlans := planGroups[payerDeptID]
if len(groupPlans) == 0 {
continue
}
groupAmount := 0.0
for _, plan := range groupPlans {
groupAmount = roundAmount(groupAmount + plan.amount)
}
if groupAmount <= 0 {
return gerror.New("加油包订单金额不能为空")
}
groupOrderSN := rc.GenerateOrderSn(&now)
payment, err := e.executePayment(
ctx,
tx,
task.main,
intent,
payerDeptID,
groupAmount,
groupOrderSN,
now,
isTopDept,
)
if err != nil {
return err
}
task.paymentSnapshot[payerDeptID] = payment
orderRow := buildAddPackageOrderDO(groupOrderSN, task.main, payerDeptID, groupAmount, payment, now)
orderID, err := e.repo.Tables().InsertContractOrder(ctx, tx, orderRow)
if err != nil {
return err
}
for _, plan := range groupPlans {
subscriptionNo := rc.GenerateSubscriptionNo("ADP")
plan.orderSN = groupOrderSN
if e.poolMode {
if err := e.insertPoolBind(ctx, tx, plan, task.main, groupOrderSN, subscriptionNo, now); err != nil {
return err
}
} else {
if err := e.insertCardBind(ctx, tx, plan, task.main, groupOrderSN, subscriptionNo, now); err != nil {
return err
}
}
item := buildAddPackageOrderItemDO(orderID, groupOrderSN, task.main, payerDeptID, deptID, plan, now)
if err := e.repo.Tables().InsertContractOrderItem(ctx, tx, item); err != nil {
return err
}
}
orderSNs = append(orderSNs, groupOrderSN)
}
if e.poolMode {
if err := e.updateFlowPool(ctx, tx, task.plans, tenantID, userID, now); err != nil {
return err
}
} else {
if err := e.updateCardTopupFlow(ctx, tx, task.plans, tenantID, userID, now); err != nil {
return err
}
}
return nil
}); err != nil {
return "", err
}
if len(orderSNs) == 0 {
return "", nil
}
return orderSNs[0], nil
}
func (e *executor) finalizeSuccess(ctx context.Context, task *batchTask, orderSN string) error {
now := e.nowTime()
tenantID := task.main.TenantId
userID := task.main.CreatedBy
deptID := task.main.CreatedDept
successCount := task.valid
failCount := task.total - task.valid
runResult := consts.BatchRunResultSuccess
asyncStatus := consts.BatchAsyncStatusSuccess
failReason := ""
if failCount > 0 && successCount > 0 {
runResult = consts.BatchRunResultPartialSuccess
asyncStatus = consts.BatchAsyncStatusPartialSuccess
failReason = firstFailureReason(task.plans)
}
if failCount > 0 && successCount == 0 {
runResult = consts.BatchRunResultFailed
asyncStatus = consts.BatchAsyncStatusFailed
failReason = firstFailureReason(task.plans)
}
if err := e.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, plan := range task.plans {
if plan == nil || plan.detail == nil {
continue
}
snapshot := buildDetailSnapshot(plan, plan.orderSN)
status := consts.BatchDetailStatusSuccess
reason := ""
if plan.reason != "" {
status = consts.BatchDetailStatusFailed
reason = plan.reason
}
if err := e.repo.Tables().UpdateBatchDetailSnapshot(ctx, tx, task.main.BatchNo, task.main.BatchType, plan.detail.RowNo, status, snapshot, reason, userID); err != nil {
return err
}
}
info := mergeBatchInfo(task.main.BatchInfo, map[string]any{
"orderSn": orderSN,
"orderSnList": collectOrderSNs(task.plans),
"priceSnapshot": buildBatchPriceSnapshot(task),
"paymentMode": task.paymentIntent.PaymentMode,
"offlineContractCompleted": task.paymentIntent.OfflineContractCompleted,
"paymentSnapshotByPayerDept": task.paymentSnapshot,
"batchMode": map[bool]string{true: "pool", false: "card"}[e.poolMode],
})
return e.repo.UpdateBatchMainInfo(ctx, tx, task.main.BatchNo, task.main.BatchType, &rc.BatchMainRow{
BatchType: task.main.BatchType,
BatchAsyncStatus: asyncStatus,
RunResult: runResult,
TotalCount: task.total,
ValidCount: task.valid,
SuccessCount: successCount,
FailCount: failCount,
FailReason: failReason,
BatchInfo: gjson.New(info),
OrderSn: orderSN,
EndTime: gtime.NewFromTime(now),
UpdatedBy: userID,
})
}); err != nil {
return gerror.Wrap(err, "finalize add package success failed")
}
rows, _ := e.repo.ResolveResultSnapshot(ctx, task.main.BatchNo, task.main.BatchType)
_ = e.repo.WriteResultFileSafe(ctx, task.main.BatchNo, task.main.BatchType, tenantID, deptID, userID, rows)
g.Log().Infof(ctx, "add package batch success, batchNo:%s, orderSn:%s, batchType:%d", task.main.BatchNo, orderSN, task.main.BatchType)
return nil
}
func (e *executor) finalizeFailure(ctx context.Context, task *batchTask, reason string) error {
now := e.nowTime()
tenantID := task.main.TenantId
userID := task.main.CreatedBy
deptID := task.main.CreatedDept
if err := e.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, plan := range task.plans {
if plan == nil || plan.detail == nil {
continue
}
snapshot := buildDetailSnapshot(plan, "")
rowReason := plan.reason
if rowReason == "" {
rowReason = reason
}
if err := e.repo.Tables().UpdateBatchDetailSnapshot(ctx, tx, task.main.BatchNo, task.main.BatchType, plan.detail.RowNo, consts.BatchDetailStatusFailed, snapshot, rowReason, userID); err != nil {
return err
}
}
return e.repo.FinalizeBatchMain(ctx, tx, task.main.BatchNo, task.main.BatchType, &rc.BatchMainRow{
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
RunResult: consts.BatchRunResultFailed,
FailReason: reason,
TotalCount: task.total,
ValidCount: task.valid,
SuccessCount: 0,
FailCount: task.total,
EndTime: gtime.NewFromTime(now),
UpdatedBy: userID,
})
}); err != nil {
return gerror.Wrap(err, "finalize add package failure failed")
}
rows, _ := e.repo.ResolveResultSnapshot(ctx, task.main.BatchNo, task.main.BatchType)
_ = e.repo.WriteResultFileSafe(ctx, task.main.BatchNo, task.main.BatchType, tenantID, deptID, userID, rows)
g.Log().Warningf(ctx, "add package batch failed, batchNo:%s, batchType:%d, reason:%s", task.main.BatchNo, task.main.BatchType, reason)
return nil
}
func buildAddPackageOrderDO(orderSN string, main *rc.BatchMainRow, payerDeptID int64, totalAmount float64, payment *PaymentSnapshot, now time.Time) *rc.OrderDO {
payTime := gtime.NewFromTime(now)
createdDept := main.CreatedDept
if createdDept <= 0 {
createdDept = payerDeptID
}
payType := consts.PayTypeBalance
balanceAmount := totalAmount
offlinePaidAmount := 0.0
payOrderSN := orderSN
if payment != nil {
payType = payment.PayType
balanceAmount = payment.BalanceAmount
offlinePaidAmount = payment.OfflinePaidAmount
if payment.BalanceDetailSerialNo != "" {
payOrderSN = payment.BalanceDetailSerialNo
}
if !payment.PaymentTime.IsZero() {
payTime = gtime.NewFromTime(payment.PaymentTime)
}
}
return &rc.OrderDO{
OrderSn: orderSN,
ParentOrderId: int64(0),
ParentOrderSn: "",
CustomerType: consts.CustomerTypeAgent,
CustomerAgentId: payerDeptID,
EndUserId: int64(0),
AgentId: payerDeptID,
OrderType: consts.OrderTypeCardSubscribe,
OrderStatus: consts.OrderStatusCompleted,
ShippingStatus: 0,
ReturnStatus: consts.OrderReturnStatusNone,
Consignee: "",
Mobile: "",
Email: "",
Address: "",
AreaCode: "",
BuyerNote: "加油包自动生成订单",
AdminNote: "",
Remark: "加油包自动生成订单",
ShippingMethod: consts.ShippingMethodNone,
LogisticsId: int64(0),
LogisticsName: "",
ShippingTypeId: int64(0),
ShippingTypeName: "",
TrackingNo: "",
PayType: payType,
PayStatus: consts.PayStatusPaid,
PayOrderSn: payOrderSN,
PayTime: payTime,
TotalAmount: totalAmount,
ProductAmount: totalAmount,
ShippingFee: 0,
ServiceFee: 0,
InvoiceFee: 0,
CouponAmount: 0,
DiscountAmount: 0,
Balance: balanceAmount,
PaidAmount: totalAmount,
OnlinePaidAmount: 0,
OfflinePaidAmount: offlinePaidAmount,
UnpaidAmount: 0,
UnrefundAmount: 0,
IsNeedCommission: 0,
DistributionStatus: 0,
TenantId: main.TenantId,
CreatedDept: createdDept,
CreatedBy: main.CreatedBy,
CreatedAt: payTime,
UpdatedBy: main.CreatedBy,
UpdatedAt: payTime,
}
}
func buildAddPackageOrderItemDO(orderID int64, orderSN string, main *rc.BatchMainRow, payerDeptID, createdDeptID int64, plan *detailPlan, now time.Time) *rc.OrderItemDO {
if plan == nil || plan.price == nil {
return nil
}
createdDept := createdDeptID
if createdDept <= 0 {
createdDept = payerDeptID
}
skuData, _ := json.Marshal([]map[string]any{
{"skuName": "targetCode", "skuValue": plan.targetCode},
{"skuName": "package", "skuValue": plan.price.PackageName, "packageCode": plan.price.PackageCode, "packageType": plan.price.PackageType},
{"skuName": "effectiveMode", "skuValue": effectiveModeText(plan.effectiveMode)},
})
extraSKUData, _ := json.Marshal(map[string]any{
"businessBatchNo": main.BatchNo,
"targetCode": plan.targetCode,
"packageCode": plan.price.PackageCode,
"effectiveMode": plan.effectiveMode,
"effectiveModeText": effectiveModeText(plan.effectiveMode),
"remark": plan.remark,
})
return &rc.OrderItemDO{
OrderId: orderID,
OrderSn: orderSN,
CustomerType: consts.CustomerTypeAgent,
CustomerAgentId: payerDeptID,
EndUserId: int64(0),
Price: plan.amount,
Quantity: 1,
ProductId: plan.price.PackageID,
ProductName: plan.price.PackageName,
ProductSn: plan.price.PackageCode,
PicThumb: "",
SkuId: int64(0),
SkuData: string(skuData),
DeliveryQuantity: 0,
ProductType: consts.ProductTypeCard,
IsGift: 0,
AgentId: payerDeptID,
PrepayPrice: 0,
Commission: "",
OriginPrice: plan.amount,
PromotionData: "",
ExtraSkuData: string(extraSKUData),
PayMode: consts.PayModeYearMonth,
ServiceBeginTime: gtime.NewFromTime(plan.effectiveTime),
ServiceEndTime: gtime.NewFromTime(plan.expireTime),
TenantId: main.TenantId,
CreatedDept: createdDept,
CreatedBy: main.CreatedBy,
CreatedAt: gtime.NewFromTime(now),
UpdatedBy: main.CreatedBy,
UpdatedAt: gtime.NewFromTime(now),
}
}
func collectOrderSNs(plans []*detailPlan) []string {
if len(plans) == 0 {
return nil
}
seen := make(map[string]struct{}, len(plans))
out := make([]string, 0, len(plans))
for _, plan := range plans {
if plan == nil || strings.TrimSpace(plan.orderSN) == "" {
continue
}
orderSN := strings.TrimSpace(plan.orderSN)
if _, ok := seen[orderSN]; ok {
continue
}
seen[orderSN] = struct{}{}
out = append(out, orderSN)
}
sort.Strings(out)
return out
}
func (e *executor) insertCardBind(ctx context.Context, tx gdb.TX, plan *detailPlan, main *rc.BatchMainRow, orderSN, subscriptionNo string, now time.Time) error {
nowGT := gtime.NewFromTime(now)
bind := &rc.SimCardBindPkg{
BatchNo: main.BatchNo,
Iccid: plan.targetCode,
Msisdn: "",
DeptId: plan.customerDeptID,
PackageType: plan.price.PackageType,
PackageCode: plan.packageCode,
PreviousPackageCode: "",
BusinessType: addPackageBizType,
EffectiveStatus: map[bool]int{true: consts.EffectiveStatusEffective, false: consts.EffectiveStatusPending}[plan.effectiveNow],
HandleTime: nowGT,
EffectiveTime: gtime.NewFromTime(plan.effectiveTime),
ExpectedExpireTime: gtime.NewFromTime(plan.expireTime),
ActualExpireTime: nil,
DurationMonths: 1,
GiftDurationMonths: 0,
OrderCode: orderSN,
SubscriptionNo: subscriptionNo,
Remark: plan.remark,
TenantId: main.TenantId,
CreatedDept: main.CreatedDept,
CreatedBy: main.CreatedBy,
CreatedAt: nowGT,
}
if err := e.repo.Tables().InsertBind(ctx, tx, bind); err != nil {
return err
}
return nil
}
func (e *executor) insertPoolBind(ctx context.Context, tx gdb.TX, plan *detailPlan, main *rc.BatchMainRow, orderSN, subscriptionNo string, now time.Time) error {
nowGT := gtime.NewFromTime(now)
_, err := tx.Ctx(ctx).Model("t_sim_pool_bind_package").Data(map[string]any{
"subscription_no": subscriptionNo,
"batch_no": main.BatchNo,
"pool_no": plan.targetCode,
"dept_id": plan.customerDeptID,
"package_type": plan.price.PackageType,
"package_code": plan.packageCode,
"previous_package_code": "",
"business_type": addPackageBizType,
"effective_status": map[bool]int{true: consts.EffectiveStatusEffective, false: consts.EffectiveStatusPending}[plan.effectiveNow],
"handle_time": nowGT,
"effective_time": gtime.NewFromTime(plan.effectiveTime),
"expected_expire_time": gtime.NewFromTime(plan.expireTime),
"actual_expire_time": nil,
"duration_months": 1,
"gift_duration_months": 0,
"order_code": orderSN,
"remark": plan.remark,
"tenant_id": main.TenantId,
"created_dept": main.CreatedDept,
"created_by": main.CreatedBy,
"created_at": nowGT,
}).Insert()
if err != nil {
return gerror.Wrap(err, "insert flow pool add package failed")
}
return nil
}
func (e *executor) updateFlowPool(ctx context.Context, tx gdb.TX, plans []*detailPlan, tenantID string, userID int64, now time.Time) error {
type poolDelta struct {
pool *flowPoolRow
flow float64
}
pools := make(map[int64]*poolDelta)
for _, plan := range plans {
if plan == nil || !plan.effectiveNow || plan.price == nil || plan.pool == nil {
continue
}
delta, ok := pools[plan.pool.Id]
if !ok {
delta = &poolDelta{pool: plan.pool}
pools[plan.pool.Id] = delta
}
delta.flow = roundAmount(delta.flow + plan.price.FlowMb)
}
if len(pools) == 0 {
return nil
}
for _, delta := range pools {
if delta == nil || delta.pool == nil || delta.flow <= 0 {
continue
}
_, err := tx.Ctx(ctx).Model("t_sim_flow_pool").
Where("id", delta.pool.Id).
Data(map[string]any{
"add_on_flow_mb": roundAmount(delta.pool.AddOnFlowMb + delta.flow),
"total_flow_mb": roundAmount(delta.pool.TotalFlowMb + delta.flow),
"remain_flow_mb": roundAmount(delta.pool.RemainFlowMb + delta.flow),
"updated_by": userID,
"updated_at": gtime.NewFromTime(now),
}).
Update()
if err != nil {
return gerror.Wrap(err, "update flow pool add-on flow failed")
}
}
return nil
}
func (e *executor) updateCardTopupFlow(ctx context.Context, tx gdb.TX, plans []*detailPlan, tenantID string, userID int64, now time.Time) error {
type cardDelta struct {
card *rc.SimCardInfo
flow float64
}
cards := make(map[int64]*cardDelta)
for _, plan := range plans {
if plan == nil || !plan.effectiveNow || plan.price == nil || plan.card == nil {
continue
}
delta, ok := cards[plan.card.Id]
if !ok {
delta = &cardDelta{card: plan.card}
cards[plan.card.Id] = delta
}
delta.flow = roundAmount(delta.flow + plan.price.FlowMb)
}
if len(cards) == 0 {
return nil
}
for _, delta := range cards {
if delta == nil || delta.card == nil || delta.card.Id <= 0 || delta.flow <= 0 {
continue
}
update := map[string]any{
"package_total_flow_mb": roundAmount(delta.card.PackageTotalFlowMb + delta.flow),
"updated_by": userID,
"updated_at": gtime.NewFromTime(now),
}
mod := tx.Ctx(ctx).Model("t_sim_card_info").Where("id", delta.card.Id)
if strings.TrimSpace(tenantID) != "" {
mod = mod.Where("tenant_id", tenantID)
}
if _, err := mod.Data(update).Update(); err != nil {
return gerror.Wrap(err, "update card add-on flow failed")
}
}
return nil
}
func normalizePaymentIntent(intent PaymentIntent, isTopDept bool) (PaymentIntent, error) {
intent.PaymentMode = strings.TrimSpace(intent.PaymentMode)
if intent.PaymentMode == "" {
intent.PaymentMode = "balance"
}
switch intent.PaymentMode {
case "balance":
intent.OfflineContractCompleted = false
return intent, nil
case "offline":
if !isTopDept {
return PaymentIntent{}, gerror.New("当前组织加油包必须使用余额支付")
}
if !intent.OfflineContractCompleted {
return PaymentIntent{}, gerror.New("请确认线下合同、销售和走账已完成")
}
return intent, nil
default:
return PaymentIntent{}, gerror.New("加油包付款方式不正确")
}
}
func (e *executor) executePayment(
ctx context.Context,
tx gdb.TX,
main *rc.BatchMainRow,
intent PaymentIntent,
payerDeptID int64,
amount float64,
orderSN string,
now time.Time,
isTopDept bool,
) (*PaymentSnapshot, error) {
if main == nil {
return nil, gerror.New("加油包批次不能为空")
}
snapshot := &PaymentSnapshot{
PaymentMode: intent.PaymentMode,
PayStatus: consts.PayStatusPaid,
PayAmount: amount,
PaymentTime: now,
PayerDeptID: payerDeptID,
PayerUserID: main.CreatedBy,
IsTopDept: isTopDept,
}
if intent.PaymentMode == "offline" {
snapshot.PayType = consts.PayTypeOffline
snapshot.OfflinePaidAmount = amount
return snapshot, nil
}
serialNo := addPackageBalanceSerialNo(main.BatchNo, payerDeptID)
before, after, err := e.debitBalance(
ctx,
tx,
main.TenantId,
payerDeptID,
amount,
main.CreatedBy,
orderSN,
serialNo,
now,
main.CreatedDept,
)
if err != nil {
return nil, err
}
snapshot.PayType = consts.PayTypeBalance
snapshot.BalanceAmount = amount
snapshot.BalanceBefore = before
snapshot.BalanceAfter = after
snapshot.BalanceDetailSerialNo = serialNo
return snapshot, nil
}
func addPackageBalanceSerialNo(batchNo string, payerDeptID int64) string {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" || payerDeptID <= 0 {
return ""
}
return fmt.Sprintf("ADPPAY%sD%d", batchNo, payerDeptID)
}
func (e *executor) isTopDept(ctx context.Context, tenantID string, deptID int64) (bool, error) {
if strings.TrimSpace(tenantID) == "" || deptID <= 0 {
return false, gerror.New("当前用户未绑定组织或租户")
}
var dept sysDeptRow
if err := e.repo.DB().Model("sys_dept").Ctx(ctx).
Where("tenant_id", tenantID).
Where("dept_id", deptID).
WhereNull("deleted_at").
Scan(&dept); err != nil {
return false, gerror.Wrap(err, "query current department failed")
}
if dept.DeptId == 0 {
return false, gerror.New("当前组织不存在")
}
return dept.ParentId == 0, nil
}
func (e *executor) recoverAppliedBusiness(ctx context.Context, task *batchTask) (string, bool, error) {
if task == nil || task.main == nil {
return "", false, nil
}
type existingOrderItem struct {
OrderSN string `orm:"order_sn"`
ExtraSKUData string `orm:"extra_sku_data"`
}
var existingItems []existingOrderItem
if err := e.repo.DB().Model("t_order_item oi").Ctx(ctx).
Fields("oi.order_sn, oi.extra_sku_data").
LeftJoin("t_order o", "o.id = oi.order_id").
Where("o.tenant_id", task.main.TenantId).
Where("oi.extra_sku_data LIKE ?", "%\"businessBatchNo\":\""+task.main.BatchNo+"\"%").
OrderAsc("oi.item_id").
Scan(&existingItems); err != nil {
return "", false, gerror.Wrap(err, "query existing add package orders failed")
}
if len(existingItems) == 0 {
return "", false, nil
}
orderByPlan := make(map[string]string, len(existingItems))
for _, item := range existingItems {
orderSN := strings.TrimSpace(item.OrderSN)
if orderSN == "" || strings.TrimSpace(item.ExtraSKUData) == "" {
continue
}
var extra struct {
BusinessBatchNo string `json:"businessBatchNo"`
TargetCode string `json:"targetCode"`
PackageCode string `json:"packageCode"`
}
if err := json.Unmarshal([]byte(item.ExtraSKUData), &extra); err != nil {
return "", false, gerror.Wrap(err, "decode existing add package order item failed")
}
if extra.BusinessBatchNo != task.main.BatchNo {
continue
}
key := addPackageRecoveryKey(extra.TargetCode, extra.PackageCode)
if key == "" {
return "", false, gerror.New("existing add package order item is missing target or package")
}
if previous, ok := orderByPlan[key]; ok && previous != orderSN {
return "", false, gerror.New("existing add package orders contain duplicate target records")
}
orderByPlan[key] = orderSN
}
matched := 0
firstOrderSN := ""
for _, plan := range task.plans {
if plan == nil || plan.reason != "" {
continue
}
key := addPackageRecoveryKey(plan.targetCode, plan.packageCode)
orderSN, ok := orderByPlan[key]
if !ok {
return "", false, gerror.New("existing add package orders are incomplete; refusing to retry business writes")
}
plan.orderSN = orderSN
if firstOrderSN == "" {
firstOrderSN = orderSN
}
matched++
}
if matched == 0 {
return "", false, nil
}
return firstOrderSN, true, nil
}
func addPackageRecoveryKey(targetCode, packageCode string) string {
targetCode = strings.TrimSpace(targetCode)
packageCode = strings.TrimSpace(packageCode)
if targetCode == "" || packageCode == "" {
return ""
}
return targetCode + "\\x00" + packageCode
}
func (e *executor) ensureNoActiveTopup(ctx context.Context, tx gdb.TX, tenantID, targetCode string, now time.Time) error {
targetCode = strings.TrimSpace(targetCode)
tenantID = strings.TrimSpace(tenantID)
if targetCode == "" || tenantID == "" {
return nil
}
if e.poolMode {
return ensureNoActiveTopupRecord(ctx, tx, rc.TableSimPoolBindPackage, "pool_no", consts.PackageTypePoolAddPackage, tenantID, targetCode, now, "当前流量池存在未过期加油包,不能重复订购")
}
return ensureNoActiveTopupRecord(ctx, tx, rc.TableSimCardBindPkg, "iccid", consts.PackageTypeCardAddPackage, tenantID, targetCode, now, "当前卡存在未过期加油包,不能重复订购")
}
func ensureNoActiveTopupRecord(ctx context.Context, tx gdb.TX, table, targetColumn string, packageType int, tenantID, targetCode string, now time.Time, message string) error {
model := g.DB().Model(table).Ctx(ctx)
if tx != nil {
model = tx.Ctx(ctx).Model(table)
}
model = model.
Where("tenant_id", tenantID).
Where(targetColumn, targetCode).
Where("package_type", packageType).
WhereIn("effective_status", []int{consts.EffectiveStatusPending, consts.EffectiveStatusEffective}).
Where("(expected_expire_time IS NULL OR expected_expire_time > ?)", gtime.NewFromTime(now))
if tx != nil {
model = model.LockUpdate()
}
var row struct {
Id int64 `orm:"id"`
}
err := model.Fields("id").Limit(1).Scan(&row)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return gerror.Wrap(err, "查询未过期加油包失败")
}
if row.Id > 0 {
return gerror.New(message)
}
return nil
}
func (e *executor) debitBalance(ctx context.Context, tx gdb.TX, tenantID string, deptID int64, amount float64, userID int64, orderSN, serialNo string, now time.Time, createdDept int64) (float64, float64, error) {
serialNo = strings.TrimSpace(serialNo)
if serialNo == "" {
return 0, 0, gerror.New("加油包余额流水号不能为空")
}
var existing balanceDetailRow
if err := tx.Ctx(ctx).Model("t_balance_detail").
Where("tenant_id", tenantID).
Where("serial_no", serialNo).
LockUpdate().
Scan(&existing); err != nil && !errors.Is(err, sql.ErrNoRows) {
return 0, 0, gerror.Wrap(err, "query add package balance detail failed")
}
if strings.TrimSpace(existing.SerialNo) != "" {
return 0, 0, gerror.New("当前加油包批次已扣款,请勿重复提交")
}
var account agentBalanceRow
if err := tx.Ctx(ctx).Model("t_agent_balance").
Where("dept_id", deptID).
Where("tenant_id", tenantID).
LockUpdate().
Scan(&account); err != nil {
return 0, 0, gerror.Wrap(err, "lock balance account failed")
}
if account.Id == 0 {
return 0, 0, gerror.New("账户余额不存在")
}
if account.Status != addPackageAccountStatusOK {
return 0, 0, gerror.New("账户余额已冻结,不能扣费")
}
before := roundAmount(account.Balance)
if before+0.0001 < amount {
return 0, 0, gerror.Newf("账户余额不足,需支付%.2f元,当前余额%.2f元", amount, before)
}
after := roundAmount(before - amount)
nowGT := gtime.NewFromTime(now)
if _, err := tx.Ctx(ctx).Model("t_agent_balance").
Where("id", account.Id).
Data(map[string]any{
"balance": after,
"updated_by": userID,
"updated_at": nowGT,
}).
Update(); err != nil {
return 0, 0, gerror.Wrap(err, "update agent balance failed")
}
if _, err := tx.Ctx(ctx).Model("t_balance_detail").Data(balanceDetailRow{
DeptId: deptID,
SerialNo: serialNo,
TransactionTime: nowGT,
IncomeExpenseType: 1,
TransactionType: addPackageTransactionType,
TransactionChannel: addPackageTransactionChan,
TransactionStatus: addPackageTransactionPaid,
Amount: amount,
BeforeBalance: before,
BalanceAfter: after,
ChannelSerialNo: serialNo,
OrderSn: orderSN,
Remark: fmt.Sprintf("加油包余额支付,批次号:%s", serialNo),
TenantId: tenantID,
CreatedDept: createdDept,
CreatedBy: userID,
CreatedAt: nowGT,
}).Insert(); err != nil {
return 0, 0, gerror.Wrap(err, "insert balance detail failed")
}
return before, after, nil
}
func (e *executor) resolvePrice(ctx context.Context, tenantID string, customerDeptID int64, packageCode string) (*rc.FlowPackageInfo, *PriceSnapshot, error) {
pkg, err := e.repo.Tables().LoadPackageByCode(ctx, tenantID, packageCode)
if err != nil || pkg == nil {
return nil, nil, gerror.New("加油包套餐不存在")
}
if pkg.Enabled != 1 {
return nil, nil, gerror.New("加油包套餐未启用")
}
parentDeptID, err := e.loadParentDeptID(ctx, tenantID, customerDeptID)
if err != nil {
return nil, nil, err
}
snapshot := &PriceSnapshot{
PackageID: pkg.Id,
PackageCode: packageCode,
PackageName: pkg.PackageName,
PackageType: pkg.PackageType,
PriceSource: "system",
FlowMb: pkg.FlowMb,
}
salePrice, err := renewchangeservice.ResolveEffectiveSalePrice(ctx, e.repo.Tables(), tenantID, parentDeptID, customerDeptID, pkg)
if err != nil {
return nil, nil, gerror.Wrap(err, "calculate add package effective price failed")
}
snapshot.PriceSource = salePrice.Rule.PriceSource
snapshot.AgentDefaultPriceID = salePrice.Rule.AgentDefaultPriceId
snapshot.TargetPriceID = salePrice.Rule.TargetPriceId
snapshot.UnitPrice = salePrice.AgentShipmentPrice
return pkg, snapshot, nil
}
func (e *executor) loadParentDeptID(ctx context.Context, tenantID string, deptID int64) (int64, error) {
if deptID <= 0 {
return 0, nil
}
var dept sysDeptRow
if err := e.repo.DB().Model("sys_dept").Ctx(ctx).
Where("tenant_id", tenantID).
Where("dept_id", deptID).
WhereNull("deleted_at").
Scan(&dept); err != nil {
return 0, gerror.Wrap(err, "query parent dept failed")
}
if dept.DeptId == 0 {
return 0, nil
}
return dept.ParentId, nil
}
func (e *executor) loadPool(ctx context.Context, tenantID, flowPoolCode string) (*flowPoolRow, error) {
var row flowPoolRow
if err := e.repo.DB().Model("t_sim_flow_pool").Ctx(ctx).
Where("tenant_id", tenantID).
Where("flow_pool_code", flowPoolCode).
Scan(&row); err != nil {
return nil, gerror.Wrap(err, "query flow pool failed")
}
if row.Id == 0 {
return nil, nil
}
return &row, nil
}
func (e *executor) nowTime() time.Time {
if e.nowFunc == nil {
return time.Now()
}
return e.nowFunc()
}
func effectiveModeFrom(info *gjson.Json, snapshot *DetailSnapshot) (string, error) {
if snapshot != nil && strings.TrimSpace(snapshot.EffectiveMode) != "" {
return normalizeEffectiveMode(snapshot.EffectiveMode)
}
if info != nil && !info.IsNil() {
if mode := strings.TrimSpace(info.Get("effectiveMode").String()); mode != "" {
return normalizeEffectiveMode(mode)
}
}
return effectiveModeCurrentMonth, nil
}
func normalizeEffectiveMode(mode string) (string, error) {
switch strings.TrimSpace(mode) {
case "", "immediate", "current_month", "this_month", "本月生效", "本月", "当月", "当月生效":
return effectiveModeCurrentMonth, nil
case "next_month", "nextMonth", "次月生效", "次月", "下月", "下月生效":
return "", gerror.New("加油包只支持本月生效")
default:
return "", gerror.New("加油包生效方式不正确")
}
}
func calcMonthWindow(mode string, now time.Time) (time.Time, time.Time, bool, error) {
if _, err := normalizeEffectiveMode(mode); err != nil {
return time.Time{}, time.Time{}, false, err
}
start := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second(), 0, now.Location())
next := time.Date(start.Year(), start.Month()+1, 1, 0, 0, 0, 0, start.Location())
return start, next.Add(-time.Second), !start.After(now), nil
}
func effectiveModeText(mode string) string {
_, _ = normalizeEffectiveMode(mode)
return effectiveModeCurrentText
}
func buildDetailSnapshot(plan *detailPlan, orderSN string) *gjson.Json {
if plan == nil {
return nil
}
data := map[string]any{
"targetCode": plan.targetCode,
"packageCode": plan.packageCode,
"basePackageKey": plan.basePackageKey,
"effectiveMode": plan.effectiveMode,
"effectiveModeText": effectiveModeText(plan.effectiveMode),
"remark": plan.remark,
"orderSn": orderSN,
}
if plan.price != nil {
data["priceSnapshot"] = plan.price
data["packageName"] = plan.price.PackageName
data["packageType"] = plan.price.PackageType
data["unitPrice"] = plan.price.UnitPrice
data["flowMb"] = plan.price.FlowMb
}
if !plan.effectiveTime.IsZero() {
data["effectiveTime"] = gtime.NewFromTime(plan.effectiveTime).Format("Y-m-d H:i:s")
}
if !plan.expireTime.IsZero() {
data["expectedExpireTime"] = gtime.NewFromTime(plan.expireTime).Format("Y-m-d H:i:s")
}
if plan.effectiveNow {
data["effectiveStatus"] = consts.EffectiveStatusEffective
} else {
data["effectiveStatus"] = consts.EffectiveStatusPending
}
return gjson.New(data)
}
func buildBatchPriceSnapshot(task *batchTask) map[string]any {
if task == nil {
return nil
}
rows := make([]map[string]any, 0, len(task.plans))
for _, plan := range task.plans {
if plan == nil || plan.price == nil {
continue
}
rows = append(rows, map[string]any{
"targetCode": plan.targetCode,
"basePackageKey": plan.basePackageKey,
"packageCode": plan.packageCode,
"effectiveMode": plan.effectiveMode,
"effectiveModeText": effectiveModeText(plan.effectiveMode),
"priceSource": plan.price.PriceSource,
"unitPrice": plan.price.UnitPrice,
})
}
return map[string]any{"rows": rows}
}
func mergeBatchInfo(prev *gjson.Json, addition map[string]any) map[string]any {
out := map[string]any{}
if prev != nil {
for k, v := range prev.Map() {
out[k] = v
}
}
for k, v := range addition {
out[k] = v
}
return out
}
func firstFailureReason(plans []*detailPlan) string {
for _, plan := range plans {
if plan != nil && plan.reason != "" {
return plan.reason
}
}
return ""
}
func packageCodesFromJSON(jsonValue *gjson.Json) []string {
if jsonValue == nil || jsonValue.IsNil() {
return nil
}
values := jsonValue.Interfaces()
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, raw := range values {
code := strings.TrimSpace(fmt.Sprint(raw))
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
out = append(out, code)
}
return out
}
func containsString(values []string, target string) bool {
target = strings.TrimSpace(target)
for _, value := range values {
if strings.TrimSpace(value) == target {
return true
}
}
return false
}
func normalizePriceRatio(ratio float64) float64 {
if ratio > 10 {
return ratio / 100
}
return ratio
}
func roundAmount(value float64) float64 {
if value < 0 {
return 0
}
return float64(int(value*100+0.5)) / 100
}