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),属既有缺陷,本仓未引入亦未修复。
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/packagecustomer"
|
||||
)
|
||||
|
||||
// ChangeExecutor executes a confirmed batch_type=102 change as one business
|
||||
// transaction. The transaction includes old subscription cleanup, the new
|
||||
// order/subscriptions, card reset, detail success, and batch success.
|
||||
type ChangeExecutor struct {
|
||||
repo *Repository
|
||||
nowFunc NowFunc
|
||||
}
|
||||
|
||||
func NewChangeExecutor(repo *Repository) *ChangeExecutor {
|
||||
if repo == nil {
|
||||
repo = NewRepository()
|
||||
}
|
||||
return &ChangeExecutor{repo: repo, nowFunc: timeNow}
|
||||
}
|
||||
|
||||
func (c *ChangeExecutor) SetNowFunc(now NowFunc) { c.nowFunc = now }
|
||||
|
||||
func (c *ChangeExecutor) Execute(ctx context.Context, batchNo string) error {
|
||||
batchNo = strings.TrimSpace(batchNo)
|
||||
if batchNo == "" {
|
||||
return gerror.New("change batch number is empty")
|
||||
}
|
||||
main, err := c.repo.LoadProcessingBatch(ctx, batchNo, consts.BatchTypeChange)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
details, err := c.repo.LoadRunningDetails(ctx, batchNo, consts.BatchTypeChange)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load change details failed")
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return c.finalizeFailure(ctx, main, "change batch has no runnable details")
|
||||
}
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
return c.finalizeFailure(ctx, main, err.Error())
|
||||
}
|
||||
if contract.BatchType != consts.BatchTypeChange {
|
||||
return c.finalizeFailure(ctx, main, "change execution contract has invalid batch type")
|
||||
}
|
||||
|
||||
now := pnow(c.nowFunc)
|
||||
orderSN, skipped, err := c.apply(ctx, main, contract, details, now)
|
||||
if err != nil {
|
||||
return c.finalizeFailure(ctx, main, err.Error())
|
||||
}
|
||||
if skipped {
|
||||
return nil
|
||||
}
|
||||
packagecustomer.PublishSyncForDeptsSafe(ctx, main.TenantId, main.CreatedBy, []int64{contract.CustomerAgentID}, "change", main.BatchNo)
|
||||
|
||||
rows, snapshotErr := c.repo.ResolveResultSnapshot(ctx, main.BatchNo, main.BatchType)
|
||||
if snapshotErr == nil {
|
||||
_ = c.repo.WriteResultFileSafe(ctx, main.BatchNo, main.BatchType, main.TenantId, main.CreatedDept, main.CreatedBy, rows)
|
||||
}
|
||||
g.Log().Infof(ctx, "change batch success, batchNo:%s, orderSn:%s, tenant:%s", main.BatchNo, orderSN, main.TenantId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// apply is deliberately the only success transaction. A duplicate delivery
|
||||
// that reaches this method after a completed transaction is safely ignored.
|
||||
func (c *ChangeExecutor) apply(ctx context.Context, main *rc.BatchMainRow, contract *ExecutionContract, details []*rc.BatchDetailRow, now time.Time) (orderSN string, skipped bool, err error) {
|
||||
iccids := uniqueIccidsFromDetails(details)
|
||||
if len(iccids) != len(details) {
|
||||
return "", false, gerror.New("change batch contains duplicate or empty ICCID")
|
||||
}
|
||||
if contract.Quantity != len(iccids) {
|
||||
return "", false, gerror.Newf("change contract quantity mismatch: contract=%d actual=%d", contract.Quantity, len(iccids))
|
||||
}
|
||||
|
||||
err = c.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
lockedMain, lockErr := c.repo.Tables().LockChangeBatch(ctx, tx, main.BatchNo)
|
||||
if lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
if lockedMain.BatchAsyncStatus != consts.BatchAsyncStatusProcessing {
|
||||
skipped = true
|
||||
return nil
|
||||
}
|
||||
lockedDetails, lockErr := c.repo.Tables().LockChangeDetails(ctx, tx, main.BatchNo)
|
||||
if lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
if err = validateRunningChangeDetails(lockedDetails, details); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cards, lockErr := c.repo.Tables().LockCardsByIccids(ctx, tx, iccids)
|
||||
if lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
if len(cards) != len(iccids) {
|
||||
return gerror.New("some change cards no longer exist")
|
||||
}
|
||||
bindRows, lockErr := c.repo.Tables().LockChangeBinds(ctx, tx, iccids)
|
||||
if lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
currentBinds, futureBinds, lockErr := resolveCurrentSubscriptions(bindRows, iccids)
|
||||
if lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
|
||||
oldPackageCode := readChangeOldPackageCode(main.BatchInfo)
|
||||
if oldPackageCode == "" {
|
||||
return gerror.New("change contract old package is missing")
|
||||
}
|
||||
newPackage, loadErr := c.repo.Tables().LoadChangePackage(ctx, tx, contract.TenantID, contract.PackageCode)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
oldPackage, loadErr := c.repo.Tables().LoadChangePackage(ctx, tx, contract.TenantID, oldPackageCode)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if newPackage.Enabled != 1 || oldPackage.Enabled != 1 {
|
||||
return gerror.New("change package is disabled")
|
||||
}
|
||||
if newPackage.PackageType != oldPackage.PackageType {
|
||||
return gerror.New("new package type differs from current package")
|
||||
}
|
||||
if !containsPackageCode(oldPackage.ChangeablePackageCodes, contract.PackageCode) {
|
||||
return gerror.New("new package is not in current package changeable range")
|
||||
}
|
||||
durationOptions, durationErr := changeDurationOptions(newPackage.RenewableDurationConfig)
|
||||
if durationErr != nil {
|
||||
return gerror.Wrap(durationErr, "load target package change duration options failed")
|
||||
}
|
||||
if !rc.ContainsInt(durationOptions, contract.DurationMonths) {
|
||||
return gerror.Newf("change duration %d months is not allowed for target package", contract.DurationMonths)
|
||||
}
|
||||
customer, loadErr := c.repo.Tables().LoadChangeDept(ctx, tx, contract.TenantID, contract.CustomerAgentID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if customer.DeptId != contract.SellerAgentID && customer.ParentId != contract.SellerAgentID {
|
||||
return gerror.New("change customer is outside the operator scope")
|
||||
}
|
||||
|
||||
sourceBatchNo := contract.SourceBatchNo
|
||||
for _, iccid := range iccids {
|
||||
card := cards[iccid]
|
||||
current := currentBinds[iccid]
|
||||
var expectedMSISDN, expectedIMSI string
|
||||
if detail := findDetailByICCID(lockedDetails, iccid); detail != nil && detail.Snapshot != nil {
|
||||
expectedMSISDN = strings.TrimSpace(detail.Snapshot.Get("msisdn").String())
|
||||
expectedIMSI = strings.TrimSpace(detail.Snapshot.Get("imsi").String())
|
||||
}
|
||||
if err = validateChangeCard(card, current, contract, iccid, oldPackageCode, sourceBatchNo, expectedMSISDN, expectedIMSI); err != nil {
|
||||
return gerror.Wrapf(err, "validate change card failed, iccid:%s", iccid)
|
||||
}
|
||||
for _, future := range futureBinds[iccid] {
|
||||
if future == nil || future.TenantId != contract.TenantID || future.DeptId != contract.CustomerAgentID {
|
||||
return gerror.Newf("future change subscription is inconsistent: %s", iccid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentList := make([]*rc.SimCardBindPkg, 0, len(iccids))
|
||||
currentOrderCodes := make([]string, 0, len(iccids))
|
||||
currentOrderQuantities := make(map[string]int)
|
||||
for _, iccid := range iccids {
|
||||
currentList = append(currentList, currentBinds[iccid])
|
||||
if orderCode := strings.TrimSpace(currentBinds[iccid].OrderCode); orderCode != "" {
|
||||
currentOrderCodes = append(currentOrderCodes, orderCode)
|
||||
currentOrderQuantities[orderCode]++
|
||||
}
|
||||
}
|
||||
if err = c.repo.Tables().ArchiveChangeBinds(ctx, tx, currentList, consts.EffectiveStatusTerminated, gtime.NewFromTime(now), main.BatchNo, contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
currentSubscriptionNos := subscriptionNos(currentList)
|
||||
if affected, deleteErr := c.repo.Tables().DeleteChangeBinds(ctx, tx, currentSubscriptionNos); deleteErr != nil {
|
||||
return deleteErr
|
||||
} else if int(affected) != len(currentSubscriptionNos) {
|
||||
return gerror.New("current change subscriptions changed concurrently")
|
||||
}
|
||||
if err = c.repo.Tables().UpdateChangeOrdersAfterRemoval(ctx, tx, currentOrderCodes, main.BatchNo, "lastUnsubscribe", currentOrderQuantities, gtime.NewFromTime(now), contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
futureList := flattenFutureBinds(futureBinds, iccids)
|
||||
futureOrderCodes := make([]string, 0, len(futureList))
|
||||
futureOrderQuantities := make(map[string]int)
|
||||
for _, bind := range futureList {
|
||||
if orderCode := strings.TrimSpace(bind.OrderCode); orderCode != "" {
|
||||
futureOrderCodes = append(futureOrderCodes, orderCode)
|
||||
futureOrderQuantities[orderCode]++
|
||||
}
|
||||
}
|
||||
if err = c.repo.Tables().ArchiveChangeBinds(ctx, tx, futureList, consts.EffectiveStatusVoided, gtime.NewFromTime(now), main.BatchNo, contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
futureSubscriptionNos := subscriptionNos(futureList)
|
||||
if affected, deleteErr := c.repo.Tables().DeleteChangeBinds(ctx, tx, futureSubscriptionNos); deleteErr != nil {
|
||||
return deleteErr
|
||||
} else if int(affected) != len(futureSubscriptionNos) {
|
||||
return gerror.New("future change subscriptions changed concurrently")
|
||||
}
|
||||
if err = c.repo.Tables().UpdateChangeOrdersAfterRemoval(ctx, tx, futureOrderCodes, main.BatchNo, "lastChange", futureOrderQuantities, gtime.NewFromTime(now), contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
beginAt, resolveErr := resolveChangeBeginAt(contract, now)
|
||||
if resolveErr != nil {
|
||||
return resolveErr
|
||||
}
|
||||
endAt := contract.ServiceEnd(beginAt)
|
||||
packageStatus := calculateChangePackageStatus(now, beginAt, endAt)
|
||||
effectiveStatus := calculateChangeEffectiveStatus(now, beginAt, endAt)
|
||||
orderSN, err = rc.NextOrderSN(now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
order, buildErr := BuildOrderDO(contract, orderSN, now)
|
||||
if buildErr != nil {
|
||||
return buildErr
|
||||
}
|
||||
orderID, insertErr := c.repo.Tables().InsertContractOrder(ctx, tx, order)
|
||||
if insertErr != nil {
|
||||
return gerror.Wrap(insertErr, "insert change order failed")
|
||||
}
|
||||
item, buildErr := BuildOrderItemDO(contract, OrderProductContract{ID: newPackage.Id, Code: newPackage.PackageCode, Name: newPackage.PackageName, Type: newPackage.PackageType}, orderID, orderSN, beginAt, endAt, now)
|
||||
if buildErr != nil {
|
||||
return buildErr
|
||||
}
|
||||
if err = c.repo.Tables().InsertContractOrderItem(ctx, tx, item); err != nil {
|
||||
return gerror.Wrap(err, "insert change order item failed")
|
||||
}
|
||||
|
||||
snapshots := make(map[int]*gjson.Json, len(lockedDetails))
|
||||
for _, detail := range lockedDetails {
|
||||
iccid := strings.TrimSpace(detail.ItemId)
|
||||
card := cards[iccid]
|
||||
newSubscriptionNo := rc.GenerateSubscriptionNo("SUB")
|
||||
newBind := buildChangeBind(contract, newPackage, card, main.BatchNo, oldPackageCode, orderSN, newSubscriptionNo, effectiveStatus, beginAt, endAt, now)
|
||||
if err = c.repo.Tables().InsertBind(ctx, tx, newBind); err != nil {
|
||||
return gerror.Wrap(err, "insert change subscription failed")
|
||||
}
|
||||
if err = c.repo.Tables().UpdateChangeCard(ctx, tx, iccid, newPackage.PackageCode, packageStatus, newPackage.FlowMb, gtime.NewFromTime(beginAt), gtime.NewFromTime(endAt), contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
snapshots[detail.RowNo] = buildChangeDetailSnapshot(detail, card, currentBinds[iccid], newSubscriptionNo, futureBinds[iccid], contract, oldPackageCode, orderSN, beginAt, endAt)
|
||||
}
|
||||
if err = c.repo.Tables().MarkChangeDetailsSuccess(ctx, tx, main.BatchNo, lockedDetails, snapshots, contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
info := mergeBatchInfo(main.BatchInfo, map[string]any{
|
||||
"orderSn": orderSN,
|
||||
"oldPackageCode": oldPackageCode,
|
||||
"newPackageCode": newPackage.PackageCode,
|
||||
"newPackageName": newPackage.PackageName,
|
||||
"durationMonths": contract.DurationMonths,
|
||||
"giftDurationMonths": contract.GiftDurationMonths,
|
||||
"effectiveMode": contract.EffectiveMode,
|
||||
"packageBeginTime": formatTime(gtime.NewFromTime(beginAt)),
|
||||
"packageEndTime": formatTime(gtime.NewFromTime(endAt)),
|
||||
"amount": contract.TotalAmount(),
|
||||
"changeTime": formatTime(gtime.NewFromTime(now)),
|
||||
})
|
||||
return c.repo.UpdateBatchMainInfo(ctx, tx, main.BatchNo, main.BatchType, buildChangeSuccessBatchMain(main, contract, gjson.New(info), orderSN, lockedMain.StartTime, now, len(lockedDetails)))
|
||||
})
|
||||
return orderSN, skipped, err
|
||||
}
|
||||
|
||||
func buildChangeBind(contract *ExecutionContract, pkg *rc.FlowPackageInfo, card *rc.SimCardInfo, batchNo, oldPackageCode, orderSN, subscriptionNo string, effectiveStatus int, beginAt, endAt, now time.Time) *rc.SimCardBindPkg {
|
||||
return &rc.SimCardBindPkg{
|
||||
BatchNo: batchNo,
|
||||
Iccid: card.Iccid,
|
||||
Msisdn: card.Msisdn,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageType: pkg.PackageType,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PreviousPackageCode: oldPackageCode,
|
||||
BusinessType: consts.PackageBusinessTypeChange,
|
||||
EffectiveStatus: effectiveStatus,
|
||||
HandleTime: gtime.NewFromTime(now),
|
||||
EffectiveTime: gtime.NewFromTime(beginAt),
|
||||
ExpectedExpireTime: gtime.NewFromTime(endAt),
|
||||
DurationMonths: contract.DurationMonths,
|
||||
GiftDurationMonths: contract.GiftDurationMonths,
|
||||
OrderCode: orderSN,
|
||||
SubscriptionNo: subscriptionNo,
|
||||
Remark: "卡变更套餐自动生成",
|
||||
TenantId: contract.TenantID,
|
||||
CreatedDept: contract.CreatedDeptID,
|
||||
CreatedBy: contract.OperatorUserID,
|
||||
CreatedAt: gtime.NewFromTime(now),
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
UpdatedAt: gtime.NewFromTime(now),
|
||||
}
|
||||
}
|
||||
|
||||
func buildChangeSuccessBatchMain(main *rc.BatchMainRow, contract *ExecutionContract, info *gjson.Json, orderSN string, startTime *gtime.Time, now time.Time, validCount int) *rc.BatchMainRow {
|
||||
return &rc.BatchMainRow{
|
||||
BatchType: main.BatchType,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
|
||||
RunResult: consts.BatchRunResultSuccess,
|
||||
TotalCount: main.TotalCount,
|
||||
ValidCount: validCount,
|
||||
SuccessCount: validCount,
|
||||
FailCount: 0,
|
||||
BatchInfo: info,
|
||||
OrderSn: orderSN,
|
||||
StartTime: startTime,
|
||||
EndTime: gtime.NewFromTime(now),
|
||||
TargetDeptId: contract.CustomerAgentID,
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ChangeExecutor) finalizeFailure(ctx context.Context, main *rc.BatchMainRow, reason string) error {
|
||||
now := pnow(c.nowFunc)
|
||||
err := c.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if err := c.repo.MarkAllDetailsFailed(ctx, tx, main.BatchNo, main.BatchType, reason, main.CreatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.repo.FinalizeBatchMain(ctx, tx, main.BatchNo, main.BatchType, &rc.BatchMainRow{
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
|
||||
RunResult: consts.BatchRunResultFailed,
|
||||
FailReason: reason,
|
||||
TotalCount: main.TotalCount,
|
||||
FailCount: main.TotalCount,
|
||||
EndTime: gtime.NewFromTime(now),
|
||||
UpdatedBy: main.CreatedBy,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "finalize change failure failed")
|
||||
}
|
||||
g.Log().Warningf(ctx, "change batch failed, batchNo:%s, reason:%s, tenant:%s", main.BatchNo, reason, main.TenantId)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRunningChangeDetails(locked, loaded []*rc.BatchDetailRow) error {
|
||||
if len(locked) != len(loaded) {
|
||||
return gerror.New("change detail count changed during execution")
|
||||
}
|
||||
for _, detail := range locked {
|
||||
if detail == nil || detail.DetailStatus != consts.BatchDetailStatusRunning {
|
||||
return gerror.New("change detail is no longer running")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateChangeCard(card *rc.SimCardInfo, bind *rc.SimCardBindPkg, contract *ExecutionContract, iccid, oldPackageCode, sourceBatchNo, expectedMSISDN, expectedIMSI string) error {
|
||||
if contract == nil {
|
||||
return gerror.New("change execution contract is missing")
|
||||
}
|
||||
return validateCurrentSubscription(card, bind, subscriptionValidation{
|
||||
ICCID: iccid,
|
||||
MSISDN: expectedMSISDN,
|
||||
IMSI: expectedIMSI,
|
||||
TenantID: contract.TenantID,
|
||||
CustomerDeptID: contract.CustomerAgentID,
|
||||
PackageCode: oldPackageCode,
|
||||
SourceBatchNo: sourceBatchNo,
|
||||
})
|
||||
}
|
||||
|
||||
func findDetailByICCID(details []*rc.BatchDetailRow, iccid string) *rc.BatchDetailRow {
|
||||
for _, detail := range details {
|
||||
if detail != nil && strings.TrimSpace(detail.ItemId) == strings.TrimSpace(iccid) {
|
||||
return detail
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readChangeOldPackageCode(info *gjson.Json) string {
|
||||
return firstString(info, "oldPackageCode", "changePrepareSnapshot.oldPackageCode")
|
||||
}
|
||||
|
||||
func containsPackageCode(values *gjson.Json, target string) bool {
|
||||
if values == nil || values.IsNil() {
|
||||
return false
|
||||
}
|
||||
for _, value := range values.Interfaces() {
|
||||
if strings.TrimSpace(gconvString(value)) == strings.TrimSpace(target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveChangeBeginAt(contract *ExecutionContract, now time.Time) (time.Time, error) {
|
||||
if contract == nil || contract.PackageBeginTime == nil {
|
||||
return time.Time{}, gerror.New("change package begin time is missing")
|
||||
}
|
||||
switch strings.TrimSpace(contract.EffectiveMode) {
|
||||
case "immediate":
|
||||
return now, nil
|
||||
case "next_month":
|
||||
begin := contract.PackageBeginTime.Time
|
||||
if begin.Day() != 1 || begin.Hour() != 0 || begin.Minute() != 0 || begin.Second() != 0 {
|
||||
return time.Time{}, gerror.New("change next month begin time is invalid")
|
||||
}
|
||||
return begin, nil
|
||||
default:
|
||||
return time.Time{}, gerror.New("change effective mode is unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
func calculateChangePackageStatus(now, begin, end time.Time) int {
|
||||
if begin.After(now) {
|
||||
return consts.PackageStatusPending
|
||||
}
|
||||
if !end.After(now) {
|
||||
return consts.PackageStatusExpired
|
||||
}
|
||||
return consts.PackageStatusEffective
|
||||
}
|
||||
|
||||
func calculateChangeEffectiveStatus(now, begin, end time.Time) int {
|
||||
if begin.After(now) {
|
||||
return consts.EffectiveStatusPending
|
||||
}
|
||||
if !end.After(now) {
|
||||
return consts.EffectiveStatusExpired
|
||||
}
|
||||
return consts.EffectiveStatusEffective
|
||||
}
|
||||
|
||||
func subscriptionNos(rows []*rc.SimCardBindPkg) []string {
|
||||
result := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row != nil {
|
||||
result = append(result, strings.TrimSpace(row.SubscriptionNo))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func flattenFutureBinds(future map[string][]*rc.SimCardBindPkg, iccids []string) []*rc.SimCardBindPkg {
|
||||
result := make([]*rc.SimCardBindPkg, 0)
|
||||
for _, iccid := range iccids {
|
||||
result = append(result, future[iccid]...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildChangeDetailSnapshot(detail *rc.BatchDetailRow, card *rc.SimCardInfo, oldBind *rc.SimCardBindPkg, newSubscriptionNo string, voided []*rc.SimCardBindPkg, contract *ExecutionContract, oldPackageCode, orderSN string, beginAt, endAt time.Time) *gjson.Json {
|
||||
data := map[string]any{}
|
||||
if detail != nil && detail.Snapshot != nil {
|
||||
for key, value := range detail.Snapshot.Map() {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
data["ICCID"] = card.Iccid
|
||||
data["MSISDN"] = card.Msisdn
|
||||
data["IMSI"] = card.Imsi
|
||||
data["iccid"] = card.Iccid
|
||||
data["msisdn"] = card.Msisdn
|
||||
data["imsi"] = card.Imsi
|
||||
data["oldPackageCode"] = oldPackageCode
|
||||
data["newPackageCode"] = contract.PackageCode
|
||||
data["newPackageName"] = contract.PackageName
|
||||
data["oldSubscriptionNo"] = oldBind.SubscriptionNo
|
||||
data["oldOrderSn"] = oldBind.OrderCode
|
||||
data["newSubscriptionNo"] = newSubscriptionNo
|
||||
data["newOrderSn"] = orderSN
|
||||
data["durationMonths"] = contract.DurationMonths
|
||||
data["giftDurationMonths"] = contract.GiftDurationMonths
|
||||
data["effectiveMode"] = contract.EffectiveMode
|
||||
data["changeUnitPrice"] = contract.UnitPrice
|
||||
data["amount"] = roundAmount(contract.UnitPrice * float64(contract.DurationMonths))
|
||||
data["newEffectiveTime"] = formatTime(gtime.NewFromTime(beginAt))
|
||||
data["newExpectedExpireTime"] = formatTime(gtime.NewFromTime(endAt))
|
||||
data["oldActualExpireTime"] = formatTime(gtime.Now())
|
||||
voidedNos := make([]string, 0, len(voided))
|
||||
for _, row := range voided {
|
||||
if row != nil {
|
||||
voidedNos = append(voidedNos, row.SubscriptionNo)
|
||||
}
|
||||
}
|
||||
data["voidedSubscriptionNos"] = voidedNos
|
||||
return gjson.New(data)
|
||||
}
|
||||
|
||||
func gconvString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
func TestResolveCurrentSubscriptionsSelectsEffectiveAndFuturePending(t *testing.T) {
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{Iccid: "8986001", SubscriptionNo: "SUB-E", EffectiveStatus: consts.EffectiveStatusEffective},
|
||||
{Iccid: "8986001", SubscriptionNo: "SUB-P", EffectiveStatus: consts.EffectiveStatusPending},
|
||||
}
|
||||
current, future, err := resolveCurrentSubscriptions(rows, []string{"8986001"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCurrentSubscriptions() error = %v", err)
|
||||
}
|
||||
if current["8986001"].SubscriptionNo != "SUB-E" {
|
||||
t.Fatalf("current subscription = %q, want SUB-E", current["8986001"].SubscriptionNo)
|
||||
}
|
||||
if len(future["8986001"]) != 1 || future["8986001"][0].SubscriptionNo != "SUB-P" {
|
||||
t.Fatalf("future subscriptions = %+v, want SUB-P", future["8986001"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentSubscriptionsRejectsDuplicateCurrentSubscriptions(t *testing.T) {
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusEffective},
|
||||
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusEffective},
|
||||
}
|
||||
if _, _, err := resolveCurrentSubscriptions(rows, []string{"8986001"}); err == nil {
|
||||
t.Fatal("resolveCurrentSubscriptions() error = nil, want duplicate-current error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChangeBeginAtUsesConfirmedNextMonthContract(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local)
|
||||
confirmed := time.Date(2026, 9, 1, 0, 0, 0, 0, time.Local)
|
||||
contract := &ExecutionContract{
|
||||
EffectiveMode: "next_month",
|
||||
PackageBeginTime: gtime.NewFromTime(confirmed),
|
||||
}
|
||||
got, err := resolveChangeBeginAt(contract, now)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveChangeBeginAt() error = %v", err)
|
||||
}
|
||||
if !got.Equal(confirmed) {
|
||||
t.Fatalf("begin time = %v, want %v", got, confirmed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChangeBeginAtUsesExecutionTimeForImmediate(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local)
|
||||
contract := &ExecutionContract{
|
||||
EffectiveMode: "immediate",
|
||||
PackageBeginTime: gtime.NewFromTime(now.Add(-time.Minute)),
|
||||
}
|
||||
got, err := resolveChangeBeginAt(contract, now)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveChangeBeginAt() error = %v", err)
|
||||
}
|
||||
if !got.Equal(now) {
|
||||
t.Fatalf("begin time = %v, want %v", got, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeAmountAndServiceEndUseConfirmedDurationAndGift(t *testing.T) {
|
||||
contract := &ExecutionContract{UnitPrice: 8.5, Quantity: 2, DurationMonths: 12, GiftDurationMonths: 2}
|
||||
if got := contract.TotalAmount(); got != 204 {
|
||||
t.Fatalf("TotalAmount() = %v, want 204", got)
|
||||
}
|
||||
begin := time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local)
|
||||
wantEnd := begin.AddDate(0, 14, 0)
|
||||
if got := contract.ServiceEnd(begin); !got.Equal(wantEnd) {
|
||||
t.Fatalf("ServiceEnd() = %v, want %v", got, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChangeBindAndSuccessBatchKeepConfirmedContractAndAuditFields(t *testing.T) {
|
||||
contract := validExecutionContract()
|
||||
contract.BatchType = consts.BatchTypeChange
|
||||
contract.UnitPrice = 8.5
|
||||
contract.Quantity = 2
|
||||
contract.DurationMonths = 12
|
||||
contract.GiftDurationMonths = 2
|
||||
now := time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local)
|
||||
begin := now.AddDate(0, 1, 0)
|
||||
end := contract.ServiceEnd(begin)
|
||||
pkg := &rc.FlowPackageInfo{Id: 300, PackageCode: contract.PackageCode, PackageName: "Changed package", PackageType: 1}
|
||||
card := &rc.SimCardInfo{Iccid: "8986001", Msisdn: "13800000000"}
|
||||
|
||||
bind := buildChangeBind(contract, pkg, card, contract.BatchNo, "PKG-OLD", "SO-NEW", "SUB-NEW", consts.EffectiveStatusPending, begin, end, now)
|
||||
if bind.CreatedAt == nil || bind.UpdatedAt == nil || !bind.CreatedAt.Time.Equal(now) || !bind.UpdatedAt.Time.Equal(now) {
|
||||
t.Fatalf("subscription audit fields = %v/%v, want %v", bind.CreatedAt, bind.UpdatedAt, now)
|
||||
}
|
||||
if bind.DurationMonths != 12 || bind.GiftDurationMonths != 2 || bind.PackageCode != contract.PackageCode {
|
||||
t.Fatalf("subscription contract = package %q, duration %d+%d", bind.PackageCode, bind.DurationMonths, bind.GiftDurationMonths)
|
||||
}
|
||||
if bind.BusinessType != consts.PackageBusinessTypeChange {
|
||||
t.Fatalf("subscription business type = %d, want %d", bind.BusinessType, consts.PackageBusinessTypeChange)
|
||||
}
|
||||
if !bind.EffectiveTime.Time.Equal(begin) || !bind.ExpectedExpireTime.Time.Equal(end) {
|
||||
t.Fatalf("subscription service period = %v/%v, want %v/%v", bind.EffectiveTime, bind.ExpectedExpireTime, begin, end)
|
||||
}
|
||||
|
||||
main := &rc.BatchMainRow{BatchType: consts.BatchTypeChange, TotalCount: 2, StartTime: gtime.NewFromTime(now.Add(-time.Minute))}
|
||||
batch := buildChangeSuccessBatchMain(main, contract, nil, "SO-NEW", main.StartTime, now, 2)
|
||||
if batch.TargetDeptId != contract.CustomerAgentID {
|
||||
t.Fatalf("target_dept_id = %d, want %d", batch.TargetDeptId, contract.CustomerAgentID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package renewchange
|
||||
|
||||
// roundAmount 四舍五入到分。
|
||||
func roundAmount(value float64) float64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(int(value*100+0.5)) / 100
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
// ExecutionContract is the confirmed renew/change contract persisted in
|
||||
// batch_main.batch_info. DurationMonths is the actual persisted service
|
||||
// duration in months; DurationDisplayValue is the value selected by the user.
|
||||
type ExecutionContract struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
SourceBatchNo string
|
||||
TenantID string
|
||||
CustomerAgentID int64
|
||||
SellerAgentID int64
|
||||
CreatedDeptID int64
|
||||
OperatorUserID int64
|
||||
PackageCode string
|
||||
PackageName string
|
||||
PriceSource string
|
||||
AgentDefaultPriceID int64
|
||||
TargetPriceID int64
|
||||
UnitPrice float64
|
||||
Quantity int
|
||||
BillingMethod int
|
||||
RenewDurationUnit string
|
||||
RenewDurationUnitText string
|
||||
DurationDisplayValue int
|
||||
DurationMonths int
|
||||
GiftDurationMonths int
|
||||
OfflineContractCompleted bool
|
||||
PaymentMode string
|
||||
PayType int
|
||||
PayStatus int
|
||||
PayAmount float64
|
||||
BalanceAmount float64
|
||||
OfflinePaidAmount float64
|
||||
BalanceDetailSerialNo string
|
||||
PaymentTime string
|
||||
PayerDeptID int64
|
||||
PayerUserID int64
|
||||
BuyerNote string
|
||||
AdminNote string
|
||||
Remark string
|
||||
EffectiveMode string
|
||||
RenewDate string
|
||||
PackageBeginTime *gtime.Time
|
||||
}
|
||||
|
||||
type OrderProductContract struct {
|
||||
ID int64
|
||||
Code string
|
||||
Name string
|
||||
Type int
|
||||
}
|
||||
|
||||
// ParseExecutionContract reads the canonical top-level contract and falls
|
||||
// back to the prepare snapshots only for batches created before the flat
|
||||
// contract migration.
|
||||
func ParseExecutionContract(main *rc.BatchMainRow) (*ExecutionContract, error) {
|
||||
if main == nil {
|
||||
return nil, gerror.New("batch main cannot be nil")
|
||||
}
|
||||
if main.BatchType != consts.BatchTypeRenew && main.BatchType != consts.BatchTypeChange {
|
||||
return nil, gerror.Newf("unsupported renew/change batch type: %d", main.BatchType)
|
||||
}
|
||||
if main.BatchInfo == nil || main.BatchInfo.IsNil() {
|
||||
return nil, gerror.New("confirmed batch contract is missing")
|
||||
}
|
||||
|
||||
var snapshotPath, packageCodeField, packageNameField, unitPriceField string
|
||||
if main.BatchType == consts.BatchTypeRenew {
|
||||
snapshotPath = "priceSnapshot"
|
||||
packageCodeField = "packageCode"
|
||||
packageNameField = "packageName"
|
||||
unitPriceField = "renewUnitPrice"
|
||||
} else {
|
||||
snapshotPath = "changePriceSnapshot"
|
||||
packageCodeField = "newPackageCode"
|
||||
packageNameField = "newPackageName"
|
||||
unitPriceField = "changeUnitPrice"
|
||||
}
|
||||
|
||||
info := main.BatchInfo
|
||||
duration := executionDurationFromInfo(info, snapshotPath)
|
||||
if main.BatchType == consts.BatchTypeRenew {
|
||||
duration = normalizeRenewExecutionDuration(info, duration)
|
||||
}
|
||||
contract := &ExecutionContract{
|
||||
BatchNo: strings.TrimSpace(main.BatchNo),
|
||||
BatchType: main.BatchType,
|
||||
SourceBatchNo: executionSourceBatchNo(main, info),
|
||||
TenantID: strings.TrimSpace(main.TenantId),
|
||||
CustomerAgentID: firstInt64(info, "customerAgentId", snapshotPath+".customerAgentId"),
|
||||
SellerAgentID: main.DeptId,
|
||||
CreatedDeptID: main.CreatedDept,
|
||||
OperatorUserID: main.CreatedBy,
|
||||
PackageCode: firstString(info, packageCodeField, snapshotPath+"."+packageCodeField),
|
||||
PackageName: firstString(info, packageNameField, snapshotPath+"."+packageNameField),
|
||||
PriceSource: firstString(info, "priceSource", snapshotPath+".priceSource"),
|
||||
AgentDefaultPriceID: firstInt64(info, "agentDefaultPriceId", snapshotPath+".agentDefaultPriceId"),
|
||||
TargetPriceID: firstInt64(info, "targetPriceId", snapshotPath+".targetPriceId"),
|
||||
Quantity: firstInt(info, "quantity"),
|
||||
BillingMethod: duration.billingMethod,
|
||||
RenewDurationUnit: duration.unit,
|
||||
RenewDurationUnitText: duration.unitText,
|
||||
DurationDisplayValue: duration.displayValue,
|
||||
DurationMonths: duration.actualMonths,
|
||||
GiftDurationMonths: duration.giftMonths,
|
||||
OfflineContractCompleted: info.Get("offlineContractCompleted").Bool(),
|
||||
PaymentMode: firstString(info, "paymentMode", "paymentSnapshot.paymentMode"),
|
||||
PayType: firstInt(info, "payType", "paymentSnapshot.payType"),
|
||||
PayStatus: firstInt(info, "payStatus", "paymentSnapshot.payStatus"),
|
||||
PayAmount: firstAmount(info, "payAmount", "paymentSnapshot.payAmount"),
|
||||
BalanceAmount: firstAmount(info, "balanceAmount", "paymentSnapshot.balanceAmount"),
|
||||
OfflinePaidAmount: firstAmount(info, "offlinePaidAmount", "paymentSnapshot.offlinePaidAmount"),
|
||||
BalanceDetailSerialNo: firstString(info, "balanceDetailSerialNo", "paymentSnapshot.balanceDetailSerialNo"),
|
||||
PaymentTime: firstString(info, "paymentTime", "paymentSnapshot.paymentTime"),
|
||||
PayerDeptID: firstInt64(info, "payerDeptId", "paymentSnapshot.payerDeptId"),
|
||||
PayerUserID: firstInt64(info, "payerUserId", "paymentSnapshot.payerUserId"),
|
||||
BuyerNote: strings.TrimSpace(info.Get("buyerNote").String()),
|
||||
AdminNote: strings.TrimSpace(info.Get("adminNote").String()),
|
||||
Remark: strings.TrimSpace(info.Get("remark").String()),
|
||||
EffectiveMode: strings.TrimSpace(info.Get("effectiveMode").String()),
|
||||
RenewDate: strings.TrimSpace(info.Get("renewDate").String()),
|
||||
}
|
||||
if value, ok := firstFloat64(info, unitPriceField, snapshotPath+"."+unitPriceField); ok {
|
||||
contract.UnitPrice = value
|
||||
} else {
|
||||
contract.UnitPrice = -1
|
||||
}
|
||||
if contract.SellerAgentID <= 0 {
|
||||
contract.SellerAgentID = main.CreatedDept
|
||||
}
|
||||
if contract.CreatedDeptID <= 0 {
|
||||
contract.CreatedDeptID = contract.SellerAgentID
|
||||
}
|
||||
contract.normalizePaymentContract()
|
||||
if raw := strings.TrimSpace(info.Get("packageBeginTime").String()); raw != "" {
|
||||
beginAt, err := parseExecutionTime(raw)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "parse package begin time failed")
|
||||
}
|
||||
contract.PackageBeginTime = beginAt
|
||||
}
|
||||
if err := contract.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contract, nil
|
||||
}
|
||||
|
||||
func executionSourceBatchNo(main *rc.BatchMainRow, info *gjson.Json) string {
|
||||
if main == nil || main.SourceType != consts.BatchSourceTypeBatchSelect {
|
||||
return ""
|
||||
}
|
||||
sourceBatchNo := firstString(info, "sourceBatchNo", "changePrepareSnapshot.sourceBatchNo")
|
||||
if sourceBatchNo == "" || sourceBatchNo == strings.TrimSpace(main.BatchNo) {
|
||||
sourceBatchNo = strings.TrimSpace(main.SourceBatchNo)
|
||||
}
|
||||
if sourceBatchNo == strings.TrimSpace(main.BatchNo) {
|
||||
return ""
|
||||
}
|
||||
return sourceBatchNo
|
||||
}
|
||||
|
||||
type executionDurationContract struct {
|
||||
billingMethod int
|
||||
unit string
|
||||
unitText string
|
||||
displayValue int
|
||||
actualMonths int
|
||||
giftMonths int
|
||||
}
|
||||
|
||||
func executionDurationFromInfo(info *gjson.Json, snapshotPath string) executionDurationContract {
|
||||
rawDurationMonths := firstInt(info, "durationMonths")
|
||||
displayValue := firstInt(info, "durationValue", "durationYears")
|
||||
hasDisplayValue := hasJSONValue(info, "durationValue") || hasJSONValue(info, "durationYears")
|
||||
if displayValue <= 0 {
|
||||
displayValue = rawDurationMonths
|
||||
}
|
||||
|
||||
billingMethod := firstInt(info, "billingMethod", snapshotPath+".billingMethod")
|
||||
unit := firstString(info, "renewDurationUnit", snapshotPath+".renewDurationUnit")
|
||||
if strings.TrimSpace(unit) == "" {
|
||||
if hasDisplayValue {
|
||||
unit = renewDurationUnitForBillingMethod(billingMethod)
|
||||
} else {
|
||||
unit = consts.RenewDurationUnitMonth
|
||||
}
|
||||
}
|
||||
unit = consts.NormalizeRenewDurationUnit(unit)
|
||||
unitText := firstString(info, "renewDurationUnitText", snapshotPath+".renewDurationUnitText")
|
||||
if strings.TrimSpace(unitText) == "" {
|
||||
unitText = consts.RenewDurationUnitText(unit)
|
||||
}
|
||||
|
||||
actualMonths := firstInt(info, "actualDurationMonths")
|
||||
if actualMonths <= 0 {
|
||||
if hasDisplayValue {
|
||||
actualMonths = consts.DurationValueToMonths(unit, displayValue)
|
||||
} else {
|
||||
actualMonths = rawDurationMonths
|
||||
}
|
||||
}
|
||||
giftMonths := firstInt(info, "actualGiftDurationMonths")
|
||||
if giftMonths <= 0 {
|
||||
giftMonths = firstInt(info, "giftDurationMonths")
|
||||
}
|
||||
if hasDisplayValue && giftMonths > 0 && firstInt(info, "actualGiftDurationMonths") <= 0 {
|
||||
giftMonths = consts.DurationValueToMonths(unit, giftMonths)
|
||||
}
|
||||
return executionDurationContract{
|
||||
billingMethod: billingMethod,
|
||||
unit: unit,
|
||||
unitText: unitText,
|
||||
displayValue: displayValue,
|
||||
actualMonths: actualMonths,
|
||||
giftMonths: giftMonths,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRenewExecutionDuration(info *gjson.Json, duration executionDurationContract) executionDurationContract {
|
||||
displayValue := firstInt(info, "durationValue", "durationYears")
|
||||
if displayValue <= 0 {
|
||||
displayValue = normalizeLegacyRenewDurationValue(firstInt(info, "durationMonths"))
|
||||
}
|
||||
if displayValue <= 0 {
|
||||
return duration
|
||||
}
|
||||
duration.unit = consts.RenewDurationUnitYear
|
||||
duration.unitText = consts.RenewDurationUnitText(duration.unit)
|
||||
duration.displayValue = displayValue
|
||||
duration.actualMonths = consts.DurationValueToMonths(duration.unit, displayValue)
|
||||
return duration
|
||||
}
|
||||
|
||||
func (c *ExecutionContract) Validate() error {
|
||||
if c == nil {
|
||||
return gerror.New("execution contract cannot be nil")
|
||||
}
|
||||
if strings.TrimSpace(c.BatchNo) == "" {
|
||||
return gerror.New("execution contract batch number is missing")
|
||||
}
|
||||
if c.BatchType != consts.BatchTypeRenew && c.BatchType != consts.BatchTypeChange {
|
||||
return gerror.New("execution contract batch type is invalid")
|
||||
}
|
||||
if strings.TrimSpace(c.TenantID) == "" {
|
||||
return gerror.New("execution contract tenant id is missing")
|
||||
}
|
||||
if c.CustomerAgentID <= 0 || c.SellerAgentID <= 0 || c.CreatedDeptID <= 0 || c.OperatorUserID <= 0 {
|
||||
return gerror.New("execution contract organization or operator is incomplete")
|
||||
}
|
||||
if strings.TrimSpace(c.PackageCode) == "" || strings.TrimSpace(c.PackageName) == "" {
|
||||
return gerror.New("execution contract package is incomplete")
|
||||
}
|
||||
if c.UnitPrice < 0 {
|
||||
return gerror.New("execution contract unit price is missing")
|
||||
}
|
||||
if c.Quantity <= 0 {
|
||||
return gerror.New("execution contract quantity must be greater than zero")
|
||||
}
|
||||
if c.DurationDisplayValue <= 0 {
|
||||
c.DurationDisplayValue = c.DurationMonths
|
||||
}
|
||||
if c.DurationMonths <= 0 {
|
||||
return gerror.New("execution contract duration months must be greater than zero")
|
||||
}
|
||||
if c.GiftDurationMonths < 0 {
|
||||
return gerror.New("execution contract gift duration months cannot be negative")
|
||||
}
|
||||
c.normalizePaymentContract()
|
||||
switch strings.TrimSpace(c.PaymentMode) {
|
||||
case "offline":
|
||||
if !c.OfflineContractCompleted {
|
||||
return gerror.New("execution contract offline confirmation is missing")
|
||||
}
|
||||
case "balance":
|
||||
if c.PayType != consts.PayTypeBalance {
|
||||
return gerror.New("execution contract balance pay type is invalid")
|
||||
}
|
||||
if c.PayAmount < 0 || c.BalanceAmount < 0 {
|
||||
return gerror.New("execution contract balance amount is invalid")
|
||||
}
|
||||
totalAmount := roundAmount(c.TotalAmount())
|
||||
if roundAmount(c.PayAmount) != totalAmount || roundAmount(c.BalanceAmount) != totalAmount {
|
||||
return gerror.New("execution contract balance payment amount does not match total amount")
|
||||
}
|
||||
if strings.TrimSpace(c.BalanceDetailSerialNo) == "" && c.PayAmount > 0 {
|
||||
return gerror.New("execution contract balance serial number is missing")
|
||||
}
|
||||
default:
|
||||
return gerror.New("execution contract payment mode is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ExecutionContract) normalizePaymentContract() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(c.PaymentMode) == "" {
|
||||
if c.PayType == consts.PayTypeBalance || c.BalanceAmount > 0 || strings.TrimSpace(c.BalanceDetailSerialNo) != "" {
|
||||
c.PaymentMode = "balance"
|
||||
} else {
|
||||
c.PaymentMode = "offline"
|
||||
}
|
||||
}
|
||||
amount := c.TotalAmount()
|
||||
if c.PayAmount <= 0 && amount > 0 {
|
||||
c.PayAmount = amount
|
||||
}
|
||||
if c.PayStatus == 0 {
|
||||
c.PayStatus = consts.PayStatusPaid
|
||||
}
|
||||
if c.PaymentMode == "offline" {
|
||||
if c.PayType == 0 {
|
||||
c.PayType = consts.PayTypeOffline
|
||||
}
|
||||
if c.OfflinePaidAmount <= 0 {
|
||||
c.OfflinePaidAmount = amount
|
||||
}
|
||||
return
|
||||
}
|
||||
if c.PaymentMode == "balance" {
|
||||
if c.PayType == 0 {
|
||||
c.PayType = consts.PayTypeBalance
|
||||
}
|
||||
if c.BalanceAmount <= 0 {
|
||||
c.BalanceAmount = amount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ExecutionContract) TotalAmount() float64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return roundAmount(c.UnitPrice * float64(c.DisplayDurationValue()) * float64(c.Quantity))
|
||||
}
|
||||
|
||||
func (c *ExecutionContract) ItemPrice() float64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return roundAmount(c.UnitPrice * float64(c.DisplayDurationValue()))
|
||||
}
|
||||
|
||||
func (c *ExecutionContract) DisplayDurationValue() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
if c.DurationDisplayValue > 0 {
|
||||
return c.DurationDisplayValue
|
||||
}
|
||||
return c.DurationMonths
|
||||
}
|
||||
|
||||
func (c *ExecutionContract) ServiceEnd(begin time.Time) time.Time {
|
||||
if c == nil {
|
||||
return begin
|
||||
}
|
||||
return addMonths(begin, c.DurationMonths+c.GiftDurationMonths)
|
||||
}
|
||||
|
||||
func BuildOrderDO(contract *ExecutionContract, orderSN string, now time.Time) (*rc.OrderDO, error) {
|
||||
if err := contract.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderSN = strings.TrimSpace(orderSN)
|
||||
if orderSN == "" {
|
||||
return nil, gerror.New("order number must be generated before building the order")
|
||||
}
|
||||
amount := contract.TotalAmount()
|
||||
balanceAmount := float64(0)
|
||||
offlinePaidAmount := amount
|
||||
payType := consts.PayTypeOffline
|
||||
payOrderSN := ""
|
||||
if contract.PaymentMode == "balance" {
|
||||
balanceAmount = contract.BalanceAmount
|
||||
offlinePaidAmount = 0
|
||||
payType = consts.PayTypeBalance
|
||||
payOrderSN = contract.BalanceDetailSerialNo
|
||||
}
|
||||
remark := contract.Remark
|
||||
if remark == "" {
|
||||
remark = "SIM card package business order"
|
||||
}
|
||||
payTime := gtime.NewFromTime(now)
|
||||
if strings.TrimSpace(contract.PaymentTime) != "" {
|
||||
if parsed, err := parseExecutionTime(strings.TrimSpace(contract.PaymentTime)); err == nil {
|
||||
payTime = parsed
|
||||
}
|
||||
}
|
||||
orderType := consts.OrderTypeCardRenew
|
||||
if contract.BatchType == consts.BatchTypeChange {
|
||||
orderType = consts.OrderTypeCardChange
|
||||
}
|
||||
return &rc.OrderDO{
|
||||
OrderSn: orderSN,
|
||||
ParentOrderId: int64(0),
|
||||
ParentOrderSn: "",
|
||||
CustomerType: consts.CustomerTypeAgent,
|
||||
CustomerAgentId: contract.CustomerAgentID,
|
||||
EndUserId: int64(0),
|
||||
AgentId: contract.SellerAgentID,
|
||||
OrderType: orderType,
|
||||
OrderStatus: consts.OrderStatusCompleted,
|
||||
ShippingStatus: 0,
|
||||
ReturnStatus: consts.OrderReturnStatusNone,
|
||||
Consignee: "",
|
||||
Mobile: "",
|
||||
Email: "",
|
||||
Address: "",
|
||||
AreaCode: "",
|
||||
BuyerNote: contract.BuyerNote,
|
||||
AdminNote: contract.AdminNote,
|
||||
Remark: remark,
|
||||
ShippingMethod: consts.ShippingMethodNone,
|
||||
LogisticsId: int64(0),
|
||||
LogisticsName: "",
|
||||
ShippingTypeId: int64(0),
|
||||
ShippingTypeName: "",
|
||||
TrackingNo: "",
|
||||
PayType: payType,
|
||||
PayStatus: consts.PayStatusPaid,
|
||||
PayOrderSn: payOrderSN,
|
||||
PayTime: payTime,
|
||||
TotalAmount: amount,
|
||||
ProductAmount: amount,
|
||||
ShippingFee: 0,
|
||||
ServiceFee: 0,
|
||||
InvoiceFee: 0,
|
||||
CouponAmount: 0,
|
||||
DiscountAmount: 0,
|
||||
Balance: balanceAmount,
|
||||
PaidAmount: amount,
|
||||
OnlinePaidAmount: 0,
|
||||
OfflinePaidAmount: offlinePaidAmount,
|
||||
UnpaidAmount: 0,
|
||||
UnrefundAmount: 0,
|
||||
IsNeedCommission: 0,
|
||||
DistributionStatus: 0,
|
||||
TenantId: contract.TenantID,
|
||||
CreatedDept: contract.CreatedDeptID,
|
||||
CreatedBy: contract.OperatorUserID,
|
||||
CreatedAt: payTime,
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
UpdatedAt: payTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildOrderItemDO(contract *ExecutionContract, product OrderProductContract, orderID int64, orderSN string, beginAt, endAt, now time.Time) (*rc.OrderItemDO, error) {
|
||||
if err := contract.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orderID <= 0 || strings.TrimSpace(orderSN) == "" {
|
||||
return nil, gerror.New("order item requires a persisted order id and order number")
|
||||
}
|
||||
if product.ID <= 0 || strings.TrimSpace(product.Code) == "" || strings.TrimSpace(product.Name) == "" {
|
||||
return nil, gerror.New("order product contract is incomplete")
|
||||
}
|
||||
if strings.TrimSpace(product.Code) != strings.TrimSpace(contract.PackageCode) {
|
||||
return nil, gerror.New("order product does not match the confirmed package")
|
||||
}
|
||||
if !endAt.After(beginAt) {
|
||||
return nil, gerror.New("order service end time must be after begin time")
|
||||
}
|
||||
|
||||
skuData, err := buildSKUData(contract, product, beginAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extraSKUData, err := buildExtraSKUData(contract, beginAt, endAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rc.OrderItemDO{
|
||||
OrderId: orderID,
|
||||
OrderSn: strings.TrimSpace(orderSN),
|
||||
CustomerType: consts.CustomerTypeAgent,
|
||||
CustomerAgentId: contract.CustomerAgentID,
|
||||
EndUserId: int64(0),
|
||||
Price: contract.ItemPrice(),
|
||||
Quantity: contract.Quantity,
|
||||
ProductId: product.ID,
|
||||
ProductName: product.Name,
|
||||
ProductSn: product.Code,
|
||||
PicThumb: "",
|
||||
SkuId: int64(0),
|
||||
SkuData: skuData,
|
||||
DeliveryQuantity: 0,
|
||||
ProductType: consts.ProductTypeCard,
|
||||
IsGift: 0,
|
||||
AgentId: contract.SellerAgentID,
|
||||
PrepayPrice: 0,
|
||||
Commission: "",
|
||||
OriginPrice: contract.ItemPrice(),
|
||||
PromotionData: "",
|
||||
ExtraSkuData: extraSKUData,
|
||||
PayMode: consts.PayModeYearMonth,
|
||||
ServiceBeginTime: gtime.NewFromTime(beginAt),
|
||||
ServiceEndTime: gtime.NewFromTime(endAt),
|
||||
TenantId: contract.TenantID,
|
||||
CreatedDept: contract.CreatedDeptID,
|
||||
CreatedBy: contract.OperatorUserID,
|
||||
CreatedAt: gtime.NewFromTime(now),
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
UpdatedAt: gtime.NewFromTime(now),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildRenewCardUpdate returns the intentionally narrow card update used by
|
||||
// renew execution. GoFrame ignores every nil field in this generated DO.
|
||||
func BuildRenewCardUpdate(contract *ExecutionContract, endAt time.Time, packageStatus int, now time.Time) (*do.TSimCardInfo, error) {
|
||||
if err := contract.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if contract.BatchType != consts.BatchTypeRenew {
|
||||
return nil, gerror.New("renew card update requires a renew contract")
|
||||
}
|
||||
if endAt.IsZero() {
|
||||
return nil, gerror.New("renew card end time cannot be empty")
|
||||
}
|
||||
if now.IsZero() {
|
||||
return nil, gerror.New("renew card update time cannot be empty")
|
||||
}
|
||||
if packageStatus < consts.PackageStatusPending || packageStatus > consts.PackageStatusExpired {
|
||||
return nil, gerror.New("renew card package status is invalid")
|
||||
}
|
||||
return &do.TSimCardInfo{
|
||||
FlowPkgEndAt: gtime.NewFromTime(endAt),
|
||||
PackageStatus: packageStatus,
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
UpdatedAt: gtime.NewFromTime(now),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildSKUData(contract *ExecutionContract, product OrderProductContract, beginAt time.Time) (string, error) {
|
||||
durationLabel := fmt.Sprintf("%d months", contract.DurationMonths)
|
||||
if contract.DurationDisplayValue > 0 && strings.TrimSpace(contract.RenewDurationUnit) != "" {
|
||||
durationLabel = renewDurationLabel(contract.DurationDisplayValue, contract.RenewDurationUnit)
|
||||
}
|
||||
data := []map[string]any{
|
||||
{"skuName": "package", "skuValue": product.Name, "packageCode": product.Code, "packageType": product.Type},
|
||||
{"skuName": "quantity", "skuValue": fmt.Sprintf("%d", contract.Quantity)},
|
||||
{"skuName": "effectiveTime", "skuValue": beginAt.Format("2006-01-02 15:04:05")},
|
||||
{"skuName": "duration", "skuValue": durationLabel},
|
||||
}
|
||||
encoded, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", gerror.Wrap(err, "marshal order SKU data failed")
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func buildExtraSKUData(contract *ExecutionContract, beginAt, endAt time.Time) (string, error) {
|
||||
data := map[string]any{
|
||||
"outboundBatchNo": contract.BatchNo,
|
||||
"businessBatchNo": contract.BatchNo,
|
||||
"offlineContractCompleted": contract.OfflineContractCompleted,
|
||||
"packageBeginTime": beginAt.Format("2006-01-02 15:04:05"),
|
||||
"packageEndTime": endAt.Format("2006-01-02 15:04:05"),
|
||||
"durationMonths": contract.DurationMonths,
|
||||
"durationValue": contract.DisplayDurationValue(),
|
||||
"actualDurationMonths": contract.DurationMonths,
|
||||
"durationUnit": strings.TrimSpace(contract.RenewDurationUnit),
|
||||
"billingMethod": contract.BillingMethod,
|
||||
"giftDurationMonths": contract.GiftDurationMonths,
|
||||
"unitPrice": contract.UnitPrice,
|
||||
"amount": contract.TotalAmount(),
|
||||
"paymentMode": contract.PaymentMode,
|
||||
"payType": contract.PayType,
|
||||
"balanceAmount": contract.BalanceAmount,
|
||||
"offlinePaidAmount": contract.OfflinePaidAmount,
|
||||
}
|
||||
if contract.BalanceDetailSerialNo != "" {
|
||||
data["balanceDetailSerialNo"] = contract.BalanceDetailSerialNo
|
||||
}
|
||||
if contract.SourceBatchNo != "" {
|
||||
data["sourceBatchNo"] = contract.SourceBatchNo
|
||||
}
|
||||
if contract.EffectiveMode != "" {
|
||||
data["effectiveMode"] = contract.EffectiveMode
|
||||
}
|
||||
encoded, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", gerror.Wrap(err, "marshal order extra SKU data failed")
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func firstString(info *gjson.Json, paths ...string) string {
|
||||
for _, path := range paths {
|
||||
value := strings.TrimSpace(info.Get(path).String())
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasJSONValue(info *gjson.Json, path string) bool {
|
||||
if info == nil || strings.TrimSpace(path) == "" {
|
||||
return false
|
||||
}
|
||||
value := info.Get(path)
|
||||
return value != nil && !value.IsNil()
|
||||
}
|
||||
|
||||
func firstInt(info *gjson.Json, paths ...string) int {
|
||||
return int(firstInt64(info, paths...))
|
||||
}
|
||||
|
||||
func firstInt64(info *gjson.Json, paths ...string) int64 {
|
||||
for _, path := range paths {
|
||||
value := info.Get(path)
|
||||
if value != nil && !value.IsNil() {
|
||||
return value.Int64()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstFloat64(info *gjson.Json, paths ...string) (float64, bool) {
|
||||
for _, path := range paths {
|
||||
value := info.Get(path)
|
||||
if value != nil && !value.IsNil() {
|
||||
return value.Float64(), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func firstAmount(info *gjson.Json, paths ...string) float64 {
|
||||
value, _ := firstFloat64(info, paths...)
|
||||
return roundAmount(value)
|
||||
}
|
||||
|
||||
func parseExecutionTime(value string) (*gtime.Time, error) {
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02"} {
|
||||
parsed, err := time.ParseInLocation(layout, value, time.Local)
|
||||
if err == nil {
|
||||
return gtime.NewFromTime(parsed), nil
|
||||
}
|
||||
}
|
||||
return nil, gerror.Newf("invalid execution time: %s", value)
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
func TestParseExecutionContractReadsConfirmedFlatContract(t *testing.T) {
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "REN20260803100250ABCDEFGH",
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
DeptId: 100,
|
||||
CreatedDept: 100,
|
||||
CreatedBy: 1,
|
||||
TenantId: "000000",
|
||||
BatchInfo: gjson.New(map[string]any{
|
||||
"customerAgentId": 200,
|
||||
"packageCode": "PKG001",
|
||||
"packageName": "Year package",
|
||||
"renewUnitPrice": 10.25,
|
||||
"quantity": 2,
|
||||
"durationMonths": 12,
|
||||
"giftDurationMonths": 1,
|
||||
"offlineContractCompleted": true,
|
||||
"buyerNote": "buyer",
|
||||
"adminNote": "admin",
|
||||
}),
|
||||
}
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExecutionContract returned error: %v", err)
|
||||
}
|
||||
if contract.DurationDisplayValue != 1 || contract.DurationMonths != 12 || contract.GiftDurationMonths != 1 {
|
||||
t.Fatalf("duration = %d/%d+%d, want display 1 and actual 12+1", contract.DurationDisplayValue, contract.DurationMonths, contract.GiftDurationMonths)
|
||||
}
|
||||
if contract.CustomerAgentID != 200 || contract.SellerAgentID != 100 {
|
||||
t.Fatalf("buyer/seller = %d/%d, want 200/100", contract.CustomerAgentID, contract.SellerAgentID)
|
||||
}
|
||||
if contract.TotalAmount() != 20.5 {
|
||||
t.Fatalf("TotalAmount = %.2f, want 20.50", contract.TotalAmount())
|
||||
}
|
||||
if contract.ItemPrice() != 10.25 {
|
||||
t.Fatalf("ItemPrice = %.2f, want 10.25", contract.ItemPrice())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecutionContractRenewDurationValueOverridesLegacyMonthSnapshot(t *testing.T) {
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "REN20260803100250LEGACY",
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
DeptId: 100,
|
||||
CreatedDept: 100,
|
||||
CreatedBy: 1,
|
||||
TenantId: "000000",
|
||||
BatchInfo: gjson.New(map[string]any{
|
||||
"customerAgentId": 200,
|
||||
"packageCode": "PKG001",
|
||||
"packageName": "Year package",
|
||||
"renewUnitPrice": 10.25,
|
||||
"quantity": 1,
|
||||
"durationValue": 1,
|
||||
"durationYears": 1,
|
||||
"durationMonths": 1,
|
||||
"actualDurationMonths": 1,
|
||||
"billingMethod": consts.BillingMethodMonth,
|
||||
"renewDurationUnit": consts.RenewDurationUnitMonth,
|
||||
"offlineContractCompleted": true,
|
||||
}),
|
||||
}
|
||||
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExecutionContract returned error: %v", err)
|
||||
}
|
||||
if contract.RenewDurationUnit != consts.RenewDurationUnitYear {
|
||||
t.Fatalf("renew duration unit = %q, want year", contract.RenewDurationUnit)
|
||||
}
|
||||
if contract.DurationDisplayValue != 1 || contract.DurationMonths != 12 {
|
||||
t.Fatalf("duration = %d/%d, want display 1 and actual 12 months", contract.DurationDisplayValue, contract.DurationMonths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecutionContractFallsBackToLegacyNestedSnapshot(t *testing.T) {
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "CHG20260803100250ABCDEFGH",
|
||||
BatchType: consts.BatchTypeChange,
|
||||
DeptId: 100,
|
||||
CreatedDept: 100,
|
||||
CreatedBy: 1,
|
||||
TenantId: "000000",
|
||||
BatchInfo: gjson.New(map[string]any{
|
||||
"changePriceSnapshot": map[string]any{
|
||||
"customerAgentId": 200,
|
||||
"newPackageCode": "PKG002",
|
||||
"newPackageName": "Changed package",
|
||||
"changeUnitPrice": 8.5,
|
||||
},
|
||||
"quantity": 1,
|
||||
"durationMonths": 24,
|
||||
"giftDurationMonths": 0,
|
||||
"offlineContractCompleted": true,
|
||||
}),
|
||||
}
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExecutionContract returned error: %v", err)
|
||||
}
|
||||
if contract.PackageCode != "PKG002" || contract.UnitPrice != 8.5 {
|
||||
t.Fatalf("legacy snapshot contract = %s/%.2f, want PKG002/8.50", contract.PackageCode, contract.UnitPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecutionContractNormalizesSourceBatchBySourceType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sourceType int
|
||||
mainSourceBatchNo string
|
||||
infoSourceBatchNo string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "input self reference is not a business source batch",
|
||||
sourceType: consts.BatchSourceTypeBatchInput,
|
||||
mainSourceBatchNo: "REN20260803161502D52JJU6B",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "file source ignores legacy source value",
|
||||
sourceType: consts.BatchSourceTypeFile,
|
||||
mainSourceBatchNo: "REN20260803161502D52JJU6B",
|
||||
infoSourceBatchNo: "OUT20260803STALE",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "batch selection keeps confirmed source batch",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSourceBatchNo: "OUT20260803MAIN",
|
||||
infoSourceBatchNo: "OUT20260803CONFIRMED",
|
||||
want: "OUT20260803CONFIRMED",
|
||||
},
|
||||
{
|
||||
name: "batch selection falls back to main source batch",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSourceBatchNo: "OUT20260803MAIN",
|
||||
want: "OUT20260803MAIN",
|
||||
},
|
||||
{
|
||||
name: "batch selection repairs self reference from main source batch",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSourceBatchNo: "OUT20260803MAIN",
|
||||
infoSourceBatchNo: "REN20260803161502D52JJU6B",
|
||||
want: "OUT20260803MAIN",
|
||||
},
|
||||
{
|
||||
name: "legacy self reference is normalized to empty",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSourceBatchNo: "REN20260803161502D52JJU6B",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
info := map[string]any{
|
||||
"customerAgentId": 200,
|
||||
"packageCode": "PKG001",
|
||||
"packageName": "Year package",
|
||||
"renewUnitPrice": 10.25,
|
||||
"quantity": 1,
|
||||
"durationMonths": 12,
|
||||
"offlineContractCompleted": true,
|
||||
}
|
||||
if tt.infoSourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = tt.infoSourceBatchNo
|
||||
}
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "REN20260803161502D52JJU6B",
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
SourceType: tt.sourceType,
|
||||
SourceBatchNo: tt.mainSourceBatchNo,
|
||||
DeptId: 100,
|
||||
CreatedDept: 100,
|
||||
CreatedBy: 1,
|
||||
TenantId: "000000",
|
||||
BatchInfo: gjson.New(info),
|
||||
}
|
||||
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExecutionContract returned error: %v", err)
|
||||
}
|
||||
if contract.SourceBatchNo != tt.want {
|
||||
t.Fatalf("SourceBatchNo = %q, want %q", contract.SourceBatchNo, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecutionContractRejectsMissingConfirmedFields(t *testing.T) {
|
||||
base := validExecutionContract()
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*ExecutionContract)
|
||||
}{
|
||||
{name: "customer", mutate: func(c *ExecutionContract) { c.CustomerAgentID = 0 }},
|
||||
{name: "duration", mutate: func(c *ExecutionContract) { c.DurationMonths = 0 }},
|
||||
{name: "quantity", mutate: func(c *ExecutionContract) { c.Quantity = 0 }},
|
||||
{name: "package", mutate: func(c *ExecutionContract) { c.PackageCode = "" }},
|
||||
{name: "offline contract", mutate: func(c *ExecutionContract) { c.OfflineContractCompleted = false }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := *base
|
||||
tc.mutate(&got)
|
||||
if err := got.Validate(); err == nil {
|
||||
t.Fatalf("Validate accepted missing %s", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOrderDocumentsUseAggregatePurchasePeriodPrice(t *testing.T) {
|
||||
contract := validExecutionContract()
|
||||
contract.UnitPrice = 10.25
|
||||
contract.Quantity = 2
|
||||
contract.DurationMonths = 12
|
||||
contract.GiftDurationMonths = 1
|
||||
now := time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local)
|
||||
orderSN := "SO20260803100250ABCDEFGH"
|
||||
|
||||
order, err := BuildOrderDO(contract, orderSN, now)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOrderDO returned error: %v", err)
|
||||
}
|
||||
if gconv.String(order.OrderSn) != orderSN {
|
||||
t.Fatalf("order_sn = %q, want %q", order.OrderSn, orderSN)
|
||||
}
|
||||
if gconv.Int64(order.CustomerAgentId) != 200 || gconv.Int64(order.AgentId) != 100 {
|
||||
t.Fatalf("order buyer/seller = %v/%v, want 200/100", order.CustomerAgentId, order.AgentId)
|
||||
}
|
||||
if gconv.Int(order.OrderType) != consts.OrderTypeCardRenew {
|
||||
t.Fatalf("order_type = %v, want renew", order.OrderType)
|
||||
}
|
||||
if gconv.Float64(order.TotalAmount) != 246 || gconv.Float64(order.ProductAmount) != 246 || gconv.Float64(order.PaidAmount) != 246 {
|
||||
t.Fatalf("order amount fields = %v/%v/%v, want 246", order.TotalAmount, order.ProductAmount, order.PaidAmount)
|
||||
}
|
||||
if order.CreatedAt == nil || !order.CreatedAt.Time.Equal(now) || order.UpdatedAt == nil || !order.UpdatedAt.Time.Equal(now) {
|
||||
t.Fatalf("order audit times = %v/%v, want %v", order.CreatedAt, order.UpdatedAt, now)
|
||||
}
|
||||
|
||||
beginAt := time.Date(2026, 8, 15, 0, 0, 0, 0, time.Local)
|
||||
endAt := contract.ServiceEnd(beginAt)
|
||||
item, err := BuildOrderItemDO(contract, OrderProductContract{
|
||||
ID: 300, Code: "PKG001", Name: "Year package", Type: 1,
|
||||
}, 900, orderSN, beginAt, endAt, now)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOrderItemDO returned error: %v", err)
|
||||
}
|
||||
if gconv.Float64(item.Price) != 123 {
|
||||
t.Fatalf("item price = %v, want totalAmount/quantity = 123", item.Price)
|
||||
}
|
||||
if gconv.Int(item.Quantity) != 2 {
|
||||
t.Fatalf("item quantity = %v, want 2", item.Quantity)
|
||||
}
|
||||
if item.CreatedAt == nil || !item.CreatedAt.Time.Equal(now) || item.UpdatedAt == nil || !item.UpdatedAt.Time.Equal(now) {
|
||||
t.Fatalf("item audit times = %v/%v, want %v", item.CreatedAt, item.UpdatedAt, now)
|
||||
}
|
||||
if got := item.ServiceEndTime.Time; !got.Equal(time.Date(2027, 9, 15, 0, 0, 0, 0, time.Local)) {
|
||||
t.Fatalf("service end = %s, want 2027-09-15", got)
|
||||
}
|
||||
if item.CreatedAt == nil || item.UpdatedAt == nil || !item.CreatedAt.Time.Equal(now) || !item.UpdatedAt.Time.Equal(now) {
|
||||
t.Fatalf("order item audit fields = %v/%v, want %v", item.CreatedAt, item.UpdatedAt, now)
|
||||
}
|
||||
|
||||
var extra map[string]any
|
||||
if err = json.Unmarshal([]byte(gconv.String(item.ExtraSkuData)), &extra); err != nil {
|
||||
t.Fatalf("extra_sku_data is invalid JSON: %v", err)
|
||||
}
|
||||
if gconv.String(extra["outboundBatchNo"]) != contract.BatchNo || gconv.Int(extra["durationMonths"]) != 12 || gconv.Int(extra["giftDurationMonths"]) != 1 {
|
||||
t.Fatalf("extra duration = %v+%v, want 12+1", extra["durationMonths"], extra["giftDurationMonths"])
|
||||
}
|
||||
if gconv.Float64(extra["amount"]) != 246 {
|
||||
t.Fatalf("extra amount = %v, want 246", extra["amount"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOrderUsesChangeOrderType(t *testing.T) {
|
||||
contract := validExecutionContract()
|
||||
contract.BatchType = consts.BatchTypeChange
|
||||
|
||||
order, err := BuildOrderDO(contract, "SO20260803100250CHANGE01", time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOrderDO returned error: %v", err)
|
||||
}
|
||||
if gconv.Int(order.OrderType) != consts.OrderTypeCardChange {
|
||||
t.Fatalf("order_type = %v, want change", order.OrderType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOrderUsesBalancePaymentSnapshot(t *testing.T) {
|
||||
contract := validExecutionContract()
|
||||
contract.OfflineContractCompleted = false
|
||||
contract.PaymentMode = "balance"
|
||||
contract.PayType = consts.PayTypeBalance
|
||||
contract.PayAmount = 120
|
||||
contract.BalanceAmount = 120
|
||||
contract.BalanceDetailSerialNo = "RENPAYREN20260803100250ABCDEFGH"
|
||||
contract.UnitPrice = 10
|
||||
contract.Quantity = 1
|
||||
contract.DurationMonths = 12
|
||||
|
||||
order, err := BuildOrderDO(contract, "SO20260803100250ABCDEFGH", time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOrderDO returned error: %v", err)
|
||||
}
|
||||
if gconv.Int(order.PayType) != consts.PayTypeBalance {
|
||||
t.Fatalf("pay_type = %v, want balance", order.PayType)
|
||||
}
|
||||
if gconv.String(order.PayOrderSn) != contract.BalanceDetailSerialNo {
|
||||
t.Fatalf("pay_order_sn = %q, want %q", order.PayOrderSn, contract.BalanceDetailSerialNo)
|
||||
}
|
||||
if gconv.Float64(order.Balance) != 120 || gconv.Float64(order.OfflinePaidAmount) != 0 {
|
||||
t.Fatalf("balance/offline = %v/%v, want 120/0", order.Balance, order.OfflinePaidAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalancePaymentSnapshotMustMatchTotalAmount(t *testing.T) {
|
||||
contract := validExecutionContract()
|
||||
contract.OfflineContractCompleted = false
|
||||
contract.PaymentMode = "balance"
|
||||
contract.PayType = consts.PayTypeBalance
|
||||
contract.PayAmount = 1
|
||||
contract.BalanceAmount = 1
|
||||
contract.BalanceDetailSerialNo = "RENPAYREN20260803100250ABCDEFGH"
|
||||
|
||||
if err := contract.Validate(); err == nil {
|
||||
t.Fatal("Validate accepted balance amount that does not match total amount")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOrderRejectsEmptyOrderNumber(t *testing.T) {
|
||||
if _, err := BuildOrderDO(validExecutionContract(), "", time.Now()); err == nil {
|
||||
t.Fatal("BuildOrderDO accepted an empty order number")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRenewCardUpdateOnlySetsRenewFields(t *testing.T) {
|
||||
contract := validExecutionContract()
|
||||
endAt := time.Date(2027, 8, 3, 0, 0, 0, 0, time.Local)
|
||||
now := time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local)
|
||||
update, err := BuildRenewCardUpdate(contract, endAt, consts.PackageStatusEffective, now)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRenewCardUpdate returned error: %v", err)
|
||||
}
|
||||
if update.FlowPkgEndAt == nil || !update.FlowPkgEndAt.Time.Equal(endAt) {
|
||||
t.Fatalf("flow_pkg_end_at = %v, want %v", update.FlowPkgEndAt, endAt)
|
||||
}
|
||||
if gconv.Int(update.PackageStatus) != consts.PackageStatusEffective || gconv.Int64(update.UpdatedBy) != contract.OperatorUserID {
|
||||
t.Fatalf("package_status/updated_by = %v/%v", update.PackageStatus, update.UpdatedBy)
|
||||
}
|
||||
if update.UpdatedAt == nil || !update.UpdatedAt.Time.Equal(now) {
|
||||
t.Fatalf("updated_at = %v, want %v", update.UpdatedAt, now)
|
||||
}
|
||||
assertOnlyRenewCardFieldsSet(t, update)
|
||||
}
|
||||
|
||||
func validExecutionContract() *ExecutionContract {
|
||||
return &ExecutionContract{
|
||||
BatchNo: "REN20260803100250ABCDEFGH",
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
TenantID: "000000",
|
||||
CustomerAgentID: 200,
|
||||
SellerAgentID: 100,
|
||||
CreatedDeptID: 100,
|
||||
OperatorUserID: 1,
|
||||
PackageCode: "PKG001",
|
||||
PackageName: "Year package",
|
||||
UnitPrice: 10,
|
||||
Quantity: 1,
|
||||
DurationMonths: 12,
|
||||
GiftDurationMonths: 0,
|
||||
OfflineContractCompleted: true,
|
||||
}
|
||||
}
|
||||
|
||||
func assertOnlyRenewCardFieldsSet(t *testing.T, update *do.TSimCardInfo) {
|
||||
t.Helper()
|
||||
allowed := map[string]bool{"FlowPkgEndAt": true, "PackageStatus": true, "UpdatedBy": true, "UpdatedAt": true}
|
||||
value := reflect.ValueOf(update).Elem()
|
||||
typ := value.Type()
|
||||
for i := 0; i < value.NumField(); i++ {
|
||||
fieldType := typ.Field(i)
|
||||
if fieldType.Name == "Meta" || allowed[fieldType.Name] {
|
||||
continue
|
||||
}
|
||||
field := value.Field(i)
|
||||
switch field.Kind() {
|
||||
case reflect.Interface, reflect.Pointer, reflect.Map, reflect.Slice:
|
||||
if !field.IsNil() {
|
||||
t.Fatalf("unexpected renew card field set: %s", fieldType.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,495 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func TestPrepareExecutorCanReclaimStaleProcessingBatch(t *testing.T) {
|
||||
now := time.Date(2026, 8, 26, 14, 0, 0, 0, time.Local)
|
||||
executor := &PrepareExecutor{nowFunc: func() time.Time { return now }}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
main *entity.BatchMain
|
||||
canRun bool
|
||||
}{
|
||||
{
|
||||
name: "fresh processing batch",
|
||||
main: &entity.BatchMain{
|
||||
BatchType: consts.BatchTypePrepare,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
StartTime: gtime.NewFromTime(now.Add(-prepareProcessingRetryAfter + time.Second)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stale processing batch",
|
||||
main: &entity.BatchMain{
|
||||
BatchType: consts.BatchTypePrepare,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
StartTime: gtime.NewFromTime(now.Add(-prepareProcessingRetryAfter)),
|
||||
},
|
||||
canRun: true,
|
||||
},
|
||||
{
|
||||
name: "missing start time",
|
||||
main: &entity.BatchMain{
|
||||
BatchType: consts.BatchTypePrepare,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
},
|
||||
canRun: true,
|
||||
},
|
||||
{
|
||||
name: "terminal batch",
|
||||
main: &entity.BatchMain{
|
||||
BatchType: consts.BatchTypePrepare,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := executor.CanExecuteProcessing(context.Background(), test.main)
|
||||
if err != nil {
|
||||
t.Fatalf("CanExecuteProcessing() error = %v", err)
|
||||
}
|
||||
if got != test.canRun {
|
||||
t.Fatalf("CanExecuteProcessing() = %v, want %v", got, test.canRun)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPrepareOptionsDurationIsExplicitOnly(t *testing.T) {
|
||||
opts := loadPrepareOptions(gjson.New(map[string]any{
|
||||
"targetBatchType": 101,
|
||||
}))
|
||||
if opts.durationSet {
|
||||
t.Fatalf("durationSet = true, want false")
|
||||
}
|
||||
if opts.durationValue != 0 {
|
||||
t.Fatalf("durationValue = %d, want 0", opts.durationValue)
|
||||
}
|
||||
|
||||
opts = loadPrepareOptions(gjson.New(map[string]any{
|
||||
"durationMonths": 6,
|
||||
}))
|
||||
if !opts.durationSet {
|
||||
t.Fatalf("durationSet = false, want true")
|
||||
}
|
||||
if opts.durationValue != 6 {
|
||||
t.Fatalf("durationValue = %d, want 6", opts.durationValue)
|
||||
}
|
||||
|
||||
opts = loadPrepareOptions(gjson.New(map[string]any{
|
||||
"durationMonths": 12,
|
||||
}))
|
||||
if !opts.durationSet {
|
||||
t.Fatalf("durationSet = false, want true")
|
||||
}
|
||||
if opts.durationValue != 1 {
|
||||
t.Fatalf("durationValue = %d, want 1", opts.durationValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDurationOptionsUseMonthsAtExecutionBoundary(t *testing.T) {
|
||||
got, err := changeDurationOptions(gjson.New([]int{1, 2, 3}))
|
||||
if err != nil {
|
||||
t.Fatalf("changeDurationOptions(years) error = %v", err)
|
||||
}
|
||||
if want := []int{12, 24, 36}; len(got) != len(want) {
|
||||
t.Fatalf("changeDurationOptions(years) = %#v, want %#v", got, want)
|
||||
} else {
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("changeDurationOptions(years) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
got, err = changeDurationOptions(gjson.New([]int{12, 24, 36}))
|
||||
if err != nil {
|
||||
t.Fatalf("changeDurationOptions(legacy months) error = %v", err)
|
||||
}
|
||||
if want := []int{12, 24, 36}; len(got) != len(want) {
|
||||
t.Fatalf("changeDurationOptions(legacy months) = %#v, want %#v", got, want)
|
||||
} else {
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("changeDurationOptions(legacy months) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeRowErrorsKeepsValidationReasons(t *testing.T) {
|
||||
got := mergeRowErrors(
|
||||
map[int]string{2: "parse failed"},
|
||||
map[int]string{1: "duration not allowed", 2: "validation failed", 0: "batch failed"},
|
||||
)
|
||||
|
||||
if got[1] != "duration not allowed" {
|
||||
t.Fatalf("row 1 reason = %q", got[1])
|
||||
}
|
||||
if got[2] != "parse failed" {
|
||||
t.Fatalf("row 2 reason = %q", got[2])
|
||||
}
|
||||
if got[0] != "batch failed" {
|
||||
t.Fatalf("row 0 reason = %q", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrepareIdentityRowsFromCardsCanonicalizesMSISDN(t *testing.T) {
|
||||
rows := []*RenewRow{
|
||||
{RowNo: 1, ICCID: "13800008000"},
|
||||
{RowNo: 2, ICCID: "8986002", MSISDN: "bad-msisdn"},
|
||||
}
|
||||
rowErrors := map[int]string{}
|
||||
|
||||
resolvePrepareIdentityRowsFromCards(rows, rowErrors, []*rc.SimCardInfo{
|
||||
{Id: 1, Iccid: "8986001", Msisdn: "13800008000", Imsi: "460001"},
|
||||
{Id: 2, Iccid: "8986002", Msisdn: "13800008002", Imsi: "460002"},
|
||||
})
|
||||
|
||||
if rows[0].ICCID != "8986001" || rows[0].MSISDN != "13800008000" || rows[0].IMSI != "460001" {
|
||||
t.Fatalf("row 1 was not canonicalized: %#v", rows[0])
|
||||
}
|
||||
if rowErrors[1] != "" {
|
||||
t.Fatalf("row 1 error = %q, want empty", rowErrors[1])
|
||||
}
|
||||
if rowErrors[2] == "" {
|
||||
t.Fatal("row 2 should fail when ICCID and MSISDN point to different identities")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectPrepareIccidsMarksDuplicateAfterCanonicalization(t *testing.T) {
|
||||
rows := []*RenewRow{
|
||||
{RowNo: 1, ICCID: "8986001"},
|
||||
{RowNo: 2, ICCID: "8986001"},
|
||||
}
|
||||
rowErrors := map[int]string{}
|
||||
|
||||
got := collectPrepareIccids(rows, rowErrors)
|
||||
|
||||
if len(got) != 1 || got[0] != "8986001" {
|
||||
t.Fatalf("iccids = %#v, want single canonical ICCID", got)
|
||||
}
|
||||
if rowErrors[1] == "" || rowErrors[2] == "" {
|
||||
t.Fatalf("duplicate errors not marked: %#v", rowErrors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrepareInfoWritesRenewFlatContract(t *testing.T) {
|
||||
got := buildPrepareInfo(nil, 101, &prepareOutcome{
|
||||
customer: &customerInfo{
|
||||
AgentID: 9,
|
||||
DeptID: 117,
|
||||
Name: "customer",
|
||||
},
|
||||
priceSnap: &RenewPriceSnapshot{
|
||||
CustomerAgentId: 117,
|
||||
PackageCode: "PKG-1",
|
||||
PackageName: "package 1",
|
||||
PriceSource: PriceSourceSystem,
|
||||
AgentDefaultPriceId: 11,
|
||||
TargetPriceId: 22,
|
||||
RenewUnitPrice: 3.5,
|
||||
RenewableDurationOptions: []int{1, 3, 12},
|
||||
RenewBeginDate: "2026-08-31",
|
||||
RenewBeginDateMin: "2026-08-31",
|
||||
RenewBeginDateMax: "2026-08-31",
|
||||
SameRenewBeginDate: true,
|
||||
},
|
||||
}, 2)
|
||||
|
||||
if got["customerAgentId"] != int64(117) {
|
||||
t.Fatalf("customerAgentId = %v, want 117", got["customerAgentId"])
|
||||
}
|
||||
if got["packageCode"] != "PKG-1" {
|
||||
t.Fatalf("packageCode = %v, want PKG-1", got["packageCode"])
|
||||
}
|
||||
if got["quantity"] != 2 {
|
||||
t.Fatalf("quantity = %v, want 2", got["quantity"])
|
||||
}
|
||||
if got["renewUnitPrice"] != 3.5 {
|
||||
t.Fatalf("renewUnitPrice = %v, want 3.5", got["renewUnitPrice"])
|
||||
}
|
||||
if got["priceSnapshot"] == nil {
|
||||
t.Fatalf("priceSnapshot is nil")
|
||||
}
|
||||
if got["renewBeginDate"] != "2026-08-31" {
|
||||
t.Fatalf("renewBeginDate = %v, want 2026-08-31", got["renewBeginDate"])
|
||||
}
|
||||
if got["sameRenewBeginDate"] != true {
|
||||
t.Fatalf("sameRenewBeginDate = %v, want true", got["sameRenewBeginDate"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillRenewBeginSnapshotSummarizesRenewBeginDate(t *testing.T) {
|
||||
snapshot := &RenewPriceSnapshot{}
|
||||
fillRenewBeginSnapshot(snapshot, []*RenewRow{
|
||||
{RowNo: 1, OldExpireTime: gtime.NewFromStr("2026-09-10 00:00:00")},
|
||||
{RowNo: 2, OldExpireTime: gtime.NewFromStr("2026-08-31 23:59:59")},
|
||||
}, nil)
|
||||
|
||||
if snapshot.RenewBeginDate != "" {
|
||||
t.Fatalf("renewBeginDate = %q, want empty for mixed dates", snapshot.RenewBeginDate)
|
||||
}
|
||||
if snapshot.RenewBeginDateMin != "2026-08-31" {
|
||||
t.Fatalf("renewBeginDateMin = %q, want 2026-08-31", snapshot.RenewBeginDateMin)
|
||||
}
|
||||
if snapshot.RenewBeginDateMax != "2026-09-10" {
|
||||
t.Fatalf("renewBeginDateMax = %q, want 2026-09-10", snapshot.RenewBeginDateMax)
|
||||
}
|
||||
if snapshot.SameRenewBeginDate {
|
||||
t.Fatalf("sameRenewBeginDate = true, want false")
|
||||
}
|
||||
|
||||
fillRenewBeginSnapshot(snapshot, []*RenewRow{
|
||||
{RowNo: 1, OldExpireTime: gtime.NewFromStr("2026-10-01 00:00:00")},
|
||||
{RowNo: 2, OldExpireTime: gtime.NewFromStr("2026-10-01 23:59:59")},
|
||||
}, nil)
|
||||
|
||||
if snapshot.RenewBeginDate != "2026-10-01" {
|
||||
t.Fatalf("renewBeginDate = %q, want 2026-10-01", snapshot.RenewBeginDate)
|
||||
}
|
||||
if !snapshot.SameRenewBeginDate {
|
||||
t.Fatalf("sameRenewBeginDate = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewBeginDateFromExpireUsesExpireDate(t *testing.T) {
|
||||
beginAt := renewBeginDateFromExpire(gtime.NewFromStr("2026-08-15 00:00:00"))
|
||||
if beginAt == nil {
|
||||
t.Fatal("renew begin date is nil")
|
||||
}
|
||||
if got := beginAt.Format("Y-m-d H:i:s"); got != "2026-08-15 23:59:59" {
|
||||
t.Fatalf("renew begin date = %s, want 2026-08-15 23:59:59", got)
|
||||
}
|
||||
if got := addMonths(beginAt.Time, 12).Format("2006-01-02 15:04:05"); got != "2027-08-15 23:59:59" {
|
||||
t.Fatalf("renew end date = %s, want 2027-08-15 23:59:59", got)
|
||||
}
|
||||
|
||||
nextBeginAt := renewBeginDateFromExpire(gtime.NewFromStr("2027-07-31 00:00:00"))
|
||||
if nextBeginAt == nil {
|
||||
t.Fatal("next renew begin date is nil")
|
||||
}
|
||||
if got := nextBeginAt.Format("Y-m-d H:i:s"); got != "2027-07-31 23:59:59" {
|
||||
t.Fatalf("next renew begin date = %s, want 2027-07-31 23:59:59", got)
|
||||
}
|
||||
|
||||
giftBeginAt := renewBeginDateFromExpire(gtime.NewFromStr("2026-08-31 00:00:00"))
|
||||
if giftBeginAt == nil {
|
||||
t.Fatal("gift renew begin date is nil")
|
||||
}
|
||||
if got := giftBeginAt.Format("Y-m-d H:i:s"); got != "2026-08-31 23:59:59" {
|
||||
t.Fatalf("gift renew begin date = %s, want 2026-08-31 23:59:59", got)
|
||||
}
|
||||
if got := addMonths(giftBeginAt.Time, 13).Format("2006-01-02 15:04:05"); got != "2027-09-30 23:59:59" {
|
||||
t.Fatalf("renew end date with gift month = %s, want 2027-09-30 23:59:59", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewSameCurrentPeriod(t *testing.T) {
|
||||
sameRows := []*RenewRow{
|
||||
{
|
||||
RowNo: 1,
|
||||
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
|
||||
},
|
||||
{
|
||||
RowNo: 2,
|
||||
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
|
||||
},
|
||||
}
|
||||
if err := validateRenewSameCurrentPeriod(sameRows, nil); err != nil {
|
||||
t.Fatalf("validateRenewSameCurrentPeriod() error = %v", err)
|
||||
}
|
||||
|
||||
diffBeginRows := []*RenewRow{
|
||||
{
|
||||
RowNo: 1,
|
||||
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
|
||||
},
|
||||
{
|
||||
RowNo: 2,
|
||||
OldEffTime: gtime.NewFromStr("2026-02-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
|
||||
},
|
||||
}
|
||||
if err := validateRenewSameCurrentPeriod(diffBeginRows, nil); err == nil {
|
||||
t.Fatalf("validateRenewSameCurrentPeriod() error = nil, want begin mismatch")
|
||||
}
|
||||
|
||||
diffExpireRows := []*RenewRow{
|
||||
{
|
||||
RowNo: 1,
|
||||
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
|
||||
},
|
||||
{
|
||||
RowNo: 2,
|
||||
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2027-01-31 23:59:59"),
|
||||
},
|
||||
}
|
||||
if err := validateRenewSameCurrentPeriod(diffExpireRows, nil); err == nil {
|
||||
t.Fatalf("validateRenewSameCurrentPeriod() error = nil, want expire mismatch")
|
||||
}
|
||||
|
||||
missingTimeRows := []*RenewRow{
|
||||
{
|
||||
RowNo: 1,
|
||||
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
|
||||
},
|
||||
{RowNo: 2, OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00")},
|
||||
}
|
||||
if err := validateRenewSameCurrentPeriod(missingTimeRows, nil); err == nil {
|
||||
t.Fatalf("validateRenewSameCurrentPeriod() error = nil, want missing time")
|
||||
}
|
||||
|
||||
rowErrors := map[int]string{2: "existing validation error"}
|
||||
if err := validateRenewSameCurrentPeriod(missingTimeRows, rowErrors); err != nil {
|
||||
t.Fatalf("validateRenewSameCurrentPeriod() with rowErrors error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewPriceOwnerUsesCurrentCustomerParent(t *testing.T) {
|
||||
if got := renewPriceOwnerDeptID(&rc.SysDeptRow{DeptId: 117, ParentId: 100}); got != 100 {
|
||||
t.Fatalf("price owner dept = %d, want 100", got)
|
||||
}
|
||||
if got := renewPriceOwnerDeptID(&rc.SysDeptRow{DeptId: 100, ParentId: 0}); got != 0 {
|
||||
t.Fatalf("top-level price owner dept = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewExpireAfterAllowedWindow(t *testing.T) {
|
||||
now := time.Date(2026, 8, 7, 16, 50, 39, 0, time.Local)
|
||||
|
||||
if renewExpireAfterAllowedWindow(nil, now) {
|
||||
t.Fatal("nil expire time should not be rejected")
|
||||
}
|
||||
if renewExpireAfterAllowedWindow(gtime.NewFromTime(now.AddDate(0, 6, 0)), now) {
|
||||
t.Fatal("exactly 6 months ahead should be allowed")
|
||||
}
|
||||
if !renewExpireAfterAllowedWindow(gtime.NewFromTime(now.AddDate(0, 6, 0).Add(time.Second)), now) {
|
||||
t.Fatal("beyond 6 months should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrepareInfoWritesChangeFlatContract(t *testing.T) {
|
||||
got := buildPrepareInfo(nil, 102, &prepareOutcome{
|
||||
customer: &customerInfo{
|
||||
DeptID: 117,
|
||||
Name: "customer",
|
||||
},
|
||||
changeSnap: &ChangePrepareSnapshot{
|
||||
SourceBatchNo: "CHG-1",
|
||||
CustomerAgentId: 117,
|
||||
CustomerName: "customer",
|
||||
OldPackageCode: "OLD",
|
||||
OldPackageName: "old package",
|
||||
OldPackageType: 101,
|
||||
OldPackageFlowMb: 1024,
|
||||
Quantity: 1,
|
||||
},
|
||||
changePrice: &ChangePriceSnapshot{
|
||||
CustomerAgentId: 117,
|
||||
NewPackageCode: "NEW",
|
||||
NewPackageName: "new package",
|
||||
PriceSource: PriceSourceAgentDefault,
|
||||
AgentDefaultPriceId: 33,
|
||||
TargetPriceId: 44,
|
||||
ChangeUnitPrice: 6.5,
|
||||
RenewableDurationOptions: []int{1, 2},
|
||||
},
|
||||
}, 1)
|
||||
|
||||
if got["customerAgentId"] != int64(117) {
|
||||
t.Fatalf("customerAgentId = %v, want 117", got["customerAgentId"])
|
||||
}
|
||||
if got["sourceBatchNo"] != "CHG-1" {
|
||||
t.Fatalf("sourceBatchNo = %v, want CHG-1", got["sourceBatchNo"])
|
||||
}
|
||||
if got["oldPackageCode"] != "OLD" {
|
||||
t.Fatalf("oldPackageCode = %v, want OLD", got["oldPackageCode"])
|
||||
}
|
||||
if got["newPackageCode"] != "NEW" {
|
||||
t.Fatalf("newPackageCode = %v, want NEW", got["newPackageCode"])
|
||||
}
|
||||
if got["changeUnitPrice"] != 6.5 {
|
||||
t.Fatalf("changeUnitPrice = %v, want 6.5", got["changeUnitPrice"])
|
||||
}
|
||||
if got["changePrepareSnapshot"] == nil || got["changePriceSnapshot"] == nil {
|
||||
t.Fatalf("change snapshots are missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrepareDetailSnapshotIncludesChangeSubscriptionContract(t *testing.T) {
|
||||
out := &prepareOutcome{
|
||||
rows: map[int]*RenewRow{
|
||||
1: {
|
||||
RowNo: 1,
|
||||
ICCID: "8986001",
|
||||
MSISDN: "13800000000",
|
||||
IMSI: "460001",
|
||||
OldPkgCode: "OLD",
|
||||
OldPkgName: "old package",
|
||||
OldBindSubNo: "SUB-OLD",
|
||||
OldBindOrdNo: "SO-OLD",
|
||||
SourceBatchNo: "OUT-1",
|
||||
},
|
||||
},
|
||||
changePrice: &ChangePriceSnapshot{
|
||||
NewPackageCode: "NEW",
|
||||
NewPackageName: "new package",
|
||||
},
|
||||
}
|
||||
snapshot := buildPrepareDetailSnapshot(&rc.BatchDetailRow{RowNo: 1}, out, 102)
|
||||
want := map[string]string{
|
||||
"oldPackageCode": "OLD",
|
||||
"oldPackageName": "old package",
|
||||
"oldSubscriptionNo": "SUB-OLD",
|
||||
"oldOrderSn": "SO-OLD",
|
||||
"sourceBatchNo": "OUT-1",
|
||||
"newPackageCode": "NEW",
|
||||
"newPackageName": "new package",
|
||||
}
|
||||
for key, expected := range want {
|
||||
if got := snapshot.Get(key).String(); got != expected {
|
||||
t.Fatalf("%s = %q, want %q", key, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceByPackageUsesAdminContractFormula(t *testing.T) {
|
||||
pkg := &rc.FlowPackageInfo{
|
||||
CostPrice: 10,
|
||||
SysDefaultRatio: 120,
|
||||
SysDefaultProfit: 1.23,
|
||||
}
|
||||
|
||||
if got := priceByPackage(pkg, nil); got != 13.23 {
|
||||
t.Fatalf("system price = %v, want 13.23", got)
|
||||
}
|
||||
|
||||
got := priceByPackage(pkg, &rc.FlowPackagePrice{
|
||||
AgentCostRatio: 80,
|
||||
AgentCostProfit: 2.5,
|
||||
})
|
||||
if got != 10.5 {
|
||||
t.Fatalf("override price = %v, want 10.5", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
type renewEffectivePriceStore interface {
|
||||
LoadDept(ctx context.Context, tenantID string, deptID int64) (*rc.SysDeptRow, error)
|
||||
LoadPriceOverride(ctx context.Context, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*rc.FlowPackagePrice, error)
|
||||
}
|
||||
|
||||
type renewEffectivePriceRule struct {
|
||||
PriceSource string
|
||||
AgentDefaultPriceId int64
|
||||
TargetPriceId int64
|
||||
AgentCostRatio float64
|
||||
AgentCostProfit float64
|
||||
}
|
||||
|
||||
type renewEffectiveSalePrice struct {
|
||||
BaseAgentCost float64
|
||||
Rule renewEffectivePriceRule
|
||||
AgentShipmentPrice float64
|
||||
}
|
||||
|
||||
type EffectivePriceStore = renewEffectivePriceStore
|
||||
type EffectivePriceRule = renewEffectivePriceRule
|
||||
type EffectiveSalePrice = renewEffectiveSalePrice
|
||||
|
||||
type renewTxEffectivePriceStore struct {
|
||||
tx gdb.TX
|
||||
}
|
||||
|
||||
func (s renewTxEffectivePriceStore) LoadDept(ctx context.Context, tenantID string, deptID int64) (*rc.SysDeptRow, error) {
|
||||
return loadRenewDeptTx(ctx, s.tx, tenantID, deptID)
|
||||
}
|
||||
|
||||
func (s renewTxEffectivePriceStore) LoadPriceOverride(ctx context.Context, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*rc.FlowPackagePrice, error) {
|
||||
return loadRenewPriceOverrideTx(ctx, s.tx, tenantID, deptID, targetAgentID, scopeType, packageCode)
|
||||
}
|
||||
|
||||
func ResolveEffectiveSalePrice(ctx context.Context, store EffectivePriceStore, tenantID string, ownerDeptID int64, targetDeptID int64, pkg *rc.FlowPackageInfo) (EffectiveSalePrice, error) {
|
||||
return resolveRenewEffectiveSalePrice(ctx, store, tenantID, ownerDeptID, targetDeptID, pkg)
|
||||
}
|
||||
|
||||
func resolveRenewEffectiveSalePrice(ctx context.Context, store renewEffectivePriceStore, tenantID string, ownerDeptID int64, targetDeptID int64, pkg *rc.FlowPackageInfo) (renewEffectiveSalePrice, error) {
|
||||
baseCost, err := resolveRenewEffectiveBaseCost(ctx, store, tenantID, ownerDeptID, pkg, make(map[int64]struct{}))
|
||||
if err != nil {
|
||||
return renewEffectiveSalePrice{}, err
|
||||
}
|
||||
rule, err := resolveRenewEffectivePriceRule(ctx, store, tenantID, ownerDeptID, targetDeptID, pkg)
|
||||
if err != nil {
|
||||
return renewEffectiveSalePrice{}, err
|
||||
}
|
||||
return renewEffectiveSalePrice{
|
||||
BaseAgentCost: baseCost,
|
||||
Rule: rule,
|
||||
AgentShipmentPrice: rc.RoundAmount(baseCost*rule.AgentCostRatio + rule.AgentCostProfit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveRenewEffectiveBaseCost(ctx context.Context, store renewEffectivePriceStore, tenantID string, deptID int64, pkg *rc.FlowPackageInfo, visited map[int64]struct{}) (float64, error) {
|
||||
if pkg == nil || strings.TrimSpace(pkg.PackageCode) == "" {
|
||||
return 0, gerror.New("套餐编码不能为空")
|
||||
}
|
||||
if deptID <= 0 {
|
||||
return rc.RoundAmount(pkg.CostPrice), nil
|
||||
}
|
||||
if _, ok := visited[deptID]; ok {
|
||||
return 0, gerror.New("组织定价层级存在循环,无法计算生效成本")
|
||||
}
|
||||
visited[deptID] = struct{}{}
|
||||
|
||||
dept, err := store.LoadDept(ctx, tenantID, deptID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if dept == nil || dept.ParentId <= 0 {
|
||||
return rc.RoundAmount(pkg.CostPrice), nil
|
||||
}
|
||||
|
||||
parentBaseCost, err := resolveRenewEffectiveBaseCost(ctx, store, tenantID, dept.ParentId, pkg, visited)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
parentRule, err := resolveRenewEffectivePriceRule(ctx, store, tenantID, dept.ParentId, deptID, pkg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rc.RoundAmount(parentBaseCost*parentRule.AgentCostRatio + parentRule.AgentCostProfit), nil
|
||||
}
|
||||
|
||||
func resolveRenewEffectivePriceRule(ctx context.Context, store renewEffectivePriceStore, tenantID string, ownerDeptID int64, targetDeptID int64, pkg *rc.FlowPackageInfo) (renewEffectivePriceRule, error) {
|
||||
rule := renewSystemPriceRule(pkg)
|
||||
if ownerDeptID <= 0 {
|
||||
return rule, nil
|
||||
}
|
||||
if targetDeptID > 0 {
|
||||
targetPrice, err := store.LoadPriceOverride(ctx, tenantID, ownerDeptID, targetDeptID, PriceScopeTargetAgent, pkg.PackageCode)
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
if targetPrice != nil {
|
||||
return renewRuleFromOverride(PriceSourceTarget, targetPrice), nil
|
||||
}
|
||||
}
|
||||
defaultPrice, err := store.LoadPriceOverride(ctx, tenantID, ownerDeptID, 0, PriceScopeAgentDefault, pkg.PackageCode)
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
if defaultPrice != nil {
|
||||
return renewRuleFromOverride(PriceSourceAgentDefault, defaultPrice), nil
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func renewSystemPriceRule(pkg *rc.FlowPackageInfo) renewEffectivePriceRule {
|
||||
if pkg == nil {
|
||||
return renewEffectivePriceRule{PriceSource: PriceSourceSystem}
|
||||
}
|
||||
return renewEffectivePriceRule{
|
||||
PriceSource: PriceSourceSystem,
|
||||
AgentCostRatio: normalizePriceRatio(pkg.SysDefaultRatio),
|
||||
AgentCostProfit: pkg.SysDefaultProfit,
|
||||
}
|
||||
}
|
||||
|
||||
func renewRuleFromOverride(source string, price *rc.FlowPackagePrice) renewEffectivePriceRule {
|
||||
rule := renewEffectivePriceRule{
|
||||
PriceSource: source,
|
||||
AgentCostRatio: normalizePriceRatio(price.AgentCostRatio),
|
||||
AgentCostProfit: price.AgentCostProfit,
|
||||
}
|
||||
if source == PriceSourceTarget {
|
||||
rule.TargetPriceId = price.Id
|
||||
} else {
|
||||
rule.AgentDefaultPriceId = price.Id
|
||||
}
|
||||
return rule
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
type fakeRenewEffectivePriceStore struct {
|
||||
depts map[int64]*rc.SysDeptRow
|
||||
prices map[renewEffectivePriceKey]*rc.FlowPackagePrice
|
||||
}
|
||||
|
||||
type renewEffectivePriceKey struct {
|
||||
deptID int64
|
||||
targetAgentID int64
|
||||
scopeType int
|
||||
packageCode string
|
||||
}
|
||||
|
||||
func (s fakeRenewEffectivePriceStore) LoadDept(ctx context.Context, tenantID string, deptID int64) (*rc.SysDeptRow, error) {
|
||||
return s.depts[deptID], nil
|
||||
}
|
||||
|
||||
func (s fakeRenewEffectivePriceStore) LoadPriceOverride(ctx context.Context, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*rc.FlowPackagePrice, error) {
|
||||
return s.prices[renewEffectivePriceKey{
|
||||
deptID: deptID,
|
||||
targetAgentID: targetAgentID,
|
||||
scopeType: scopeType,
|
||||
packageCode: packageCode,
|
||||
}], nil
|
||||
}
|
||||
|
||||
func TestResolveRenewEffectiveSalePriceUsesRecursiveParentCost(t *testing.T) {
|
||||
store := fakeRenewEffectivePriceStore{
|
||||
depts: map[int64]*rc.SysDeptRow{
|
||||
100: {DeptId: 100, ParentId: 0},
|
||||
117: {DeptId: 117, ParentId: 100},
|
||||
131: {DeptId: 131, ParentId: 117},
|
||||
},
|
||||
prices: map[renewEffectivePriceKey]*rc.FlowPackagePrice{
|
||||
{deptID: 100, targetAgentID: 117, scopeType: PriceScopeTargetAgent, packageCode: "yd-100m-3y"}: {
|
||||
Id: 86,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 4,
|
||||
},
|
||||
{deptID: 117, targetAgentID: 0, scopeType: PriceScopeAgentDefault, packageCode: "yd-100m-3y"}: {
|
||||
Id: 91,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 1,
|
||||
},
|
||||
{deptID: 117, targetAgentID: 131, scopeType: PriceScopeTargetAgent, packageCode: "yd-100m-3y"}: {
|
||||
Id: 92,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := resolveRenewEffectiveSalePrice(
|
||||
context.Background(),
|
||||
store,
|
||||
"000000",
|
||||
117,
|
||||
131,
|
||||
&rc.FlowPackageInfo{
|
||||
PackageCode: "yd-100m-3y",
|
||||
CostPrice: 10,
|
||||
SysDefaultRatio: 1,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRenewEffectiveSalePrice() error = %v", err)
|
||||
}
|
||||
if got.BaseAgentCost != 14 {
|
||||
t.Fatalf("base agent cost = %.2f, want 14.00", got.BaseAgentCost)
|
||||
}
|
||||
if got.AgentShipmentPrice != 19 {
|
||||
t.Fatalf("agent shipment price = %.2f, want 19.00", got.AgentShipmentPrice)
|
||||
}
|
||||
if got.Rule.PriceSource != PriceSourceTarget || got.Rule.TargetPriceId != 92 {
|
||||
t.Fatalf("rule = %+v, want target price 92", got.Rule)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
var errRenewAlreadyHandled = errors.New("renew batch was already handled")
|
||||
|
||||
// RenewExecutor executes a confirmed type=101 batch. All business writes and
|
||||
// the success state are committed by one database transaction.
|
||||
type RenewExecutor struct {
|
||||
repo *Repository
|
||||
nowFunc NowFunc
|
||||
}
|
||||
|
||||
func NewRenewExecutor(repo *Repository) *RenewExecutor {
|
||||
if repo == nil {
|
||||
repo = NewRepository()
|
||||
}
|
||||
return &RenewExecutor{repo: repo, nowFunc: timeNow}
|
||||
}
|
||||
|
||||
func (r *RenewExecutor) SetNowFunc(now NowFunc) { r.nowFunc = now }
|
||||
|
||||
func (r *RenewExecutor) Execute(ctx context.Context, batchNo string) error {
|
||||
batchNo = strings.TrimSpace(batchNo)
|
||||
if batchNo == "" {
|
||||
return gerror.New("renew batch number is empty")
|
||||
}
|
||||
|
||||
main, err := r.repo.LoadProcessingBatch(ctx, batchNo, consts.BatchTypeRenew)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(main.OrderSn) != "" {
|
||||
g.Log().Warningf(ctx, "renew batch already has order, skip duplicate: batchNo=%s orderSn=%s", batchNo, main.OrderSn)
|
||||
return nil
|
||||
}
|
||||
details, err := r.repo.LoadRunningDetails(ctx, batchNo, consts.BatchTypeRenew)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load renew details failed")
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return r.finalizeFailure(ctx, main, "renew batch has no runnable details")
|
||||
}
|
||||
now := pnow(r.nowFunc)
|
||||
orderSN, err := r.apply(ctx, main, details, now)
|
||||
if errors.Is(err, errRenewAlreadyHandled) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return r.finalizeFailure(ctx, main, err.Error())
|
||||
}
|
||||
|
||||
snap, _ := r.repo.ResolveResultSnapshot(ctx, main.BatchNo, main.BatchType)
|
||||
_ = r.repo.WriteResultFileSafe(ctx, main.BatchNo, main.BatchType, main.TenantId, main.CreatedDept, main.CreatedBy, snap)
|
||||
g.Log().Infof(ctx, "renew batch success, batchNo=%s, orderSn=%s, tenant=%s", main.BatchNo, orderSN, main.TenantId)
|
||||
return nil
|
||||
}
|
||||
|
||||
type renewExecution struct {
|
||||
detail *rc.BatchDetailRow
|
||||
card *rc.SimCardInfo
|
||||
bind *rc.SimCardBindPkg
|
||||
begin time.Time
|
||||
end time.Time
|
||||
}
|
||||
|
||||
func (r *RenewExecutor) apply(ctx context.Context, main *rc.BatchMainRow, details []*rc.BatchDetailRow, now time.Time) (string, error) {
|
||||
iccids, err := strictIccidsFromDetails(details)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var orderSN string
|
||||
err = r.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
lockedMain, err := r.repo.Tables().LockRenewBatchMain(ctx, tx, main.BatchNo, consts.BatchTypeRenew)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(lockedMain.OrderSn) != "" || lockedMain.BatchAsyncStatus != consts.BatchAsyncStatusProcessing {
|
||||
return errRenewAlreadyHandled
|
||||
}
|
||||
lockedDetails, err := r.repo.Tables().LockRenewDetails(ctx, tx, main.BatchNo, consts.BatchTypeRenew)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = validateRunningRenewDetails(lockedDetails, details); err != nil {
|
||||
return err
|
||||
}
|
||||
details = lockedDetails
|
||||
|
||||
cards, err := r.repo.Tables().LockCardsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bindRows, err := r.repo.Tables().LockRenewBindRowsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
binds, futureBinds, err := resolveCurrentSubscriptions(bindRows, iccids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contract, err := r.ensureRenewExecutionContract(ctx, tx, lockedMain, details, cards, binds, futureBinds, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
executions, err := validateRenewExecutions(contract, details, cards, binds, futureBinds, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkg, err := r.repo.Tables().LoadRenewPackage(ctx, tx, contract.TenantID, contract.PackageCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
orderSN, err = rc.NextOrderSN(now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
order, err := BuildOrderDO(contract, orderSN, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
orderID, err := r.repo.Tables().InsertContractOrder(ctx, tx, order)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "insert renew order failed")
|
||||
}
|
||||
minBegin, maxEnd := renewServiceRange(executions)
|
||||
item, err := BuildOrderItemDO(contract, OrderProductContract{
|
||||
ID: pkg.Id,
|
||||
Code: pkg.PackageCode,
|
||||
Name: pkg.PackageName,
|
||||
Type: pkg.PackageType,
|
||||
}, orderID, orderSN, minBegin, maxEnd, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = r.repo.Tables().InsertContractOrderItem(ctx, tx, item); err != nil {
|
||||
return gerror.Wrap(err, "insert renew order item failed")
|
||||
}
|
||||
|
||||
for _, execution := range executions {
|
||||
subscriptionNo, err := generateRenewSubscriptionNo(now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newBind := buildRenewBind(contract, pkg, execution, orderSN, subscriptionNo, now)
|
||||
if err = r.repo.Tables().InsertBind(ctx, tx, newBind); err != nil {
|
||||
return err
|
||||
}
|
||||
packageStatus := renewPackageStatus(now, execution.card.FlowPkgBeginAt, newBind.ExpectedExpireTime)
|
||||
cardUpdate, err := BuildRenewCardUpdate(contract, execution.end, packageStatus, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = r.repo.Tables().UpdateRenewCard(ctx, tx, execution.card.Iccid, cardUpdate); err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot := buildRenewDetailSnapshot(execution, contract, pkg, orderSN, subscriptionNo)
|
||||
if err = r.repo.MarkDetailSuccess(ctx, tx, main.BatchNo, consts.BatchTypeRenew, execution.detail.RowNo, snapshot, contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
info := mergeBatchInfo(lockedMain.BatchInfo, map[string]any{
|
||||
"orderSn": orderSN,
|
||||
"packageCode": contract.PackageCode,
|
||||
"packageName": pkg.PackageName,
|
||||
"packageType": pkg.PackageType,
|
||||
"billingMethod": contract.BillingMethod,
|
||||
"renewDurationUnit": contract.RenewDurationUnit,
|
||||
"renewDurationUnitText": contract.RenewDurationUnitText,
|
||||
"durationValue": contract.DisplayDurationValue(),
|
||||
"actualDurationMonths": contract.DurationMonths,
|
||||
"durationMonths": contract.DurationMonths,
|
||||
"giftDurationMonths": contract.GiftDurationMonths,
|
||||
"amount": contract.TotalAmount(),
|
||||
"quantity": contract.Quantity,
|
||||
"renewTime": now.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
if err = r.repo.UpdateBatchMainInfo(ctx, tx, main.BatchNo, consts.BatchTypeRenew, &rc.BatchMainRow{
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
|
||||
RunResult: consts.BatchRunResultSuccess,
|
||||
TotalCount: lockedMain.TotalCount,
|
||||
ValidCount: len(executions),
|
||||
SuccessCount: len(executions),
|
||||
FailCount: 0,
|
||||
BatchInfo: gjson.New(info),
|
||||
OrderSn: orderSN,
|
||||
StartTime: lockedMain.StartTime,
|
||||
EndTime: gtime.NewFromTime(now),
|
||||
TargetDeptId: contract.CustomerAgentID,
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return orderSN, err
|
||||
}
|
||||
|
||||
func validateRenewExecutions(contract *ExecutionContract, details []*rc.BatchDetailRow, cards map[string]*rc.SimCardInfo, binds map[string]*rc.SimCardBindPkg, futureBinds map[string][]*rc.SimCardBindPkg, now time.Time) ([]renewExecution, error) {
|
||||
if contract == nil {
|
||||
return nil, gerror.New("renew execution contract is missing")
|
||||
}
|
||||
if contract.Quantity != len(details) {
|
||||
return nil, gerror.New("renew detail quantity does not match confirmed contract")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(details))
|
||||
result := make([]renewExecution, 0, len(details))
|
||||
for _, detail := range details {
|
||||
if detail == nil {
|
||||
return nil, gerror.New("renew detail is nil")
|
||||
}
|
||||
iccid := strings.TrimSpace(detail.ItemId)
|
||||
if iccid == "" {
|
||||
return nil, gerror.New("renew detail ICCID is empty")
|
||||
}
|
||||
if _, exists := seen[iccid]; exists {
|
||||
return nil, gerror.Newf("renew detail ICCID is duplicated: %s", iccid)
|
||||
}
|
||||
seen[iccid] = struct{}{}
|
||||
card := cards[iccid]
|
||||
if card == nil {
|
||||
return nil, gerror.Newf("renew card not found: %s", iccid)
|
||||
}
|
||||
bind := binds[iccid]
|
||||
if bind == nil {
|
||||
return nil, gerror.Newf("renew latest subscription not found: %s", iccid)
|
||||
}
|
||||
var expectedMSISDN, expectedIMSI string
|
||||
if detail.Snapshot != nil {
|
||||
expectedMSISDN = strings.TrimSpace(detail.Snapshot.Get("msisdn").String())
|
||||
expectedIMSI = strings.TrimSpace(detail.Snapshot.Get("imsi").String())
|
||||
}
|
||||
if err := validateCurrentSubscription(card, bind, subscriptionValidation{
|
||||
ICCID: iccid,
|
||||
MSISDN: expectedMSISDN,
|
||||
IMSI: expectedIMSI,
|
||||
TenantID: contract.TenantID,
|
||||
CustomerDeptID: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
SourceBatchNo: contract.SourceBatchNo,
|
||||
}); err != nil {
|
||||
return nil, gerror.Wrapf(err, "validate renew current subscription failed, iccid:%s", iccid)
|
||||
}
|
||||
// The latest subscription is the source of truth for the next renewal
|
||||
// start. The legacy Admin transaction did not require the denormalized
|
||||
// card expiration to equal that subscription; future subscriptions can
|
||||
// legitimately make the two values differ.
|
||||
renewBase := renewBaseSubscriptionForCard(card, bind, futureBinds[iccid])
|
||||
if err := validateRenewBaseWindow(renewBase, now, iccid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
begin, ok := renewBeginTimeFromExpire(renewBase.ExpectedExpireTime)
|
||||
if !ok {
|
||||
return nil, gerror.Newf("ICCID没有预计到期时间:%s", strings.TrimSpace(iccid))
|
||||
}
|
||||
if contract.PackageBeginTime != nil {
|
||||
begin = contract.PackageBeginTime.Time
|
||||
}
|
||||
result = append(result, renewExecution{
|
||||
detail: detail,
|
||||
card: card,
|
||||
bind: renewBase,
|
||||
begin: begin,
|
||||
end: contract.ServiceEnd(begin),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func renewBaseSubscription(current *rc.SimCardBindPkg, future []*rc.SimCardBindPkg) *rc.SimCardBindPkg {
|
||||
base := current
|
||||
for _, bind := range future {
|
||||
if bind == nil {
|
||||
continue
|
||||
}
|
||||
if base == nil || subscriptionAfter(bind, base) {
|
||||
base = bind
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func renewBaseSubscriptionForCard(card *rc.SimCardInfo, current *rc.SimCardBindPkg, future []*rc.SimCardBindPkg) *rc.SimCardBindPkg {
|
||||
base := renewBaseSubscription(current, future)
|
||||
cardExpireAt := latestCardRenewExpireTime(card)
|
||||
if cardExpireAt == nil {
|
||||
return base
|
||||
}
|
||||
if base == nil {
|
||||
return &rc.SimCardBindPkg{
|
||||
Iccid: strings.TrimSpace(card.Iccid),
|
||||
PackageCode: strings.TrimSpace(card.CurrentPackageCode),
|
||||
ExpectedExpireTime: cardExpireAt,
|
||||
}
|
||||
}
|
||||
if base.ExpectedExpireTime != nil && !cardExpireAt.After(base.ExpectedExpireTime) {
|
||||
return base
|
||||
}
|
||||
clone := *base
|
||||
clone.ExpectedExpireTime = cardExpireAt
|
||||
return &clone
|
||||
}
|
||||
|
||||
func latestCardRenewExpireTime(card *rc.SimCardInfo) *gtime.Time {
|
||||
if card == nil {
|
||||
return nil
|
||||
}
|
||||
var expireAt *gtime.Time
|
||||
for _, candidate := range []*gtime.Time{
|
||||
card.FlowPkgEndAt,
|
||||
card.FlowPkgPresentAt,
|
||||
card.FlowPkgRealPresent,
|
||||
} {
|
||||
if candidate == nil || candidate.IsZero() {
|
||||
continue
|
||||
}
|
||||
if expireAt == nil || candidate.After(expireAt) {
|
||||
expireAt = candidate
|
||||
}
|
||||
}
|
||||
return expireAt
|
||||
}
|
||||
|
||||
func validateRenewBaseWindow(base *rc.SimCardBindPkg, now time.Time, iccid string) error {
|
||||
if base == nil || base.ExpectedExpireTime == nil {
|
||||
return gerror.Newf("ICCID没有预计到期时间:%s", strings.TrimSpace(iccid))
|
||||
}
|
||||
if renewExpireAfterAllowedWindow(base.ExpectedExpireTime, now) {
|
||||
return gerror.Newf("%s:%s", renewExpireFailureReason, strings.TrimSpace(iccid))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRunningRenewDetails(locked, loaded []*rc.BatchDetailRow) error {
|
||||
if len(locked) != len(loaded) {
|
||||
return gerror.New("renew detail count changed during execution")
|
||||
}
|
||||
for i, detail := range locked {
|
||||
if detail == nil || detail.DetailStatus != consts.BatchDetailStatusRunning {
|
||||
return gerror.New("renew detail is no longer running")
|
||||
}
|
||||
loadedDetail := loaded[i]
|
||||
if loadedDetail == nil || detail.Id != loadedDetail.Id || detail.RowNo != loadedDetail.RowNo ||
|
||||
strings.TrimSpace(detail.ItemId) != strings.TrimSpace(loadedDetail.ItemId) {
|
||||
return gerror.New("renew detail changed during execution")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renewServiceRange(executions []renewExecution) (time.Time, time.Time) {
|
||||
minBegin := executions[0].begin
|
||||
maxEnd := executions[0].end
|
||||
for _, execution := range executions[1:] {
|
||||
if execution.begin.Before(minBegin) {
|
||||
minBegin = execution.begin
|
||||
}
|
||||
if execution.end.After(maxEnd) {
|
||||
maxEnd = execution.end
|
||||
}
|
||||
}
|
||||
return minBegin, maxEnd
|
||||
}
|
||||
|
||||
func generateRenewSubscriptionNo(now time.Time) (string, error) {
|
||||
orderSN, err := rc.NextOrderSN(now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "SUB" + strings.TrimPrefix(orderSN, "SO"), nil
|
||||
}
|
||||
|
||||
func buildRenewBind(contract *ExecutionContract, pkg *rc.FlowPackageInfo, execution renewExecution, orderSN, subscriptionNo string, now time.Time) *rc.SimCardBindPkg {
|
||||
remark := contract.Remark
|
||||
if remark == "" {
|
||||
remark = "card renew subscription"
|
||||
}
|
||||
return &rc.SimCardBindPkg{
|
||||
SubscriptionNo: subscriptionNo,
|
||||
BatchNo: contract.BatchNo,
|
||||
Iccid: execution.card.Iccid,
|
||||
Msisdn: execution.card.Msisdn,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageType: pkg.PackageType,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PreviousPackageCode: pkg.PackageCode,
|
||||
BusinessType: consts.PackageBusinessTypeRenew,
|
||||
EffectiveStatus: consts.EffectiveStatusPending,
|
||||
HandleTime: gtime.NewFromTime(now),
|
||||
EffectiveTime: gtime.NewFromTime(execution.begin),
|
||||
ExpectedExpireTime: gtime.NewFromTime(execution.end),
|
||||
DurationMonths: contract.DurationMonths,
|
||||
GiftDurationMonths: contract.GiftDurationMonths,
|
||||
OrderCode: orderSN,
|
||||
Remark: remark,
|
||||
TenantId: contract.TenantID,
|
||||
CreatedDept: contract.CreatedDeptID,
|
||||
CreatedBy: contract.OperatorUserID,
|
||||
CreatedAt: gtime.NewFromTime(now),
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
UpdatedAt: gtime.NewFromTime(now),
|
||||
}
|
||||
}
|
||||
|
||||
func buildRenewDetailSnapshot(execution renewExecution, contract *ExecutionContract, pkg *rc.FlowPackageInfo, orderSN, subscriptionNo string) *gjson.Json {
|
||||
data := map[string]any{}
|
||||
if execution.detail.Snapshot != nil {
|
||||
for key, value := range execution.detail.Snapshot.Map() {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
data["iccid"] = execution.card.Iccid
|
||||
data["msisdn"] = execution.card.Msisdn
|
||||
data["imsi"] = execution.card.Imsi
|
||||
data["renewBatchNo"] = contract.BatchNo
|
||||
data["packageCode"] = pkg.PackageCode
|
||||
data["packageName"] = pkg.PackageName
|
||||
data["oldSubscriptionNo"] = execution.bind.SubscriptionNo
|
||||
data["newSubscriptionNo"] = subscriptionNo
|
||||
data["orderSn"] = orderSN
|
||||
data["oldExpectedExpireTime"] = formatRenewTime(execution.bind.ExpectedExpireTime)
|
||||
data["renewEffectiveTime"] = formatRenewTime(gtime.NewFromTime(execution.begin))
|
||||
data["renewExpectedExpireTime"] = formatRenewTime(gtime.NewFromTime(execution.end))
|
||||
data["cardFlowPkgEndAt"] = formatRenewTime(gtime.NewFromTime(execution.end))
|
||||
data["durationValue"] = contract.DisplayDurationValue()
|
||||
data["actualDurationMonths"] = contract.DurationMonths
|
||||
data["durationMonths"] = contract.DurationMonths
|
||||
data["giftDurationMonths"] = contract.GiftDurationMonths
|
||||
data["billingMethod"] = contract.BillingMethod
|
||||
data["renewDurationUnit"] = contract.RenewDurationUnit
|
||||
data["renewDurationUnitText"] = contract.RenewDurationUnitText
|
||||
data["renewDate"] = contract.RenewDate
|
||||
data["amount"] = contract.TotalAmount()
|
||||
data["renewUnitPrice"] = contract.UnitPrice
|
||||
data["priceSource"] = contract.PriceSource
|
||||
data["rowNo"] = execution.detail.RowNo
|
||||
if contract.SourceBatchNo != "" {
|
||||
data["sourceBatchNo"] = contract.SourceBatchNo
|
||||
}
|
||||
return gjson.New(data)
|
||||
}
|
||||
|
||||
func formatRenewTime(value *gtime.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return value.Format("Y-m-d H:i:s")
|
||||
}
|
||||
|
||||
func renewPackageStatus(now time.Time, begin, end *gtime.Time) int {
|
||||
if begin != nil && begin.After(gtime.NewFromTime(now)) {
|
||||
return consts.PackageStatusPending
|
||||
}
|
||||
if end != nil && !end.After(gtime.NewFromTime(now)) {
|
||||
return consts.PackageStatusExpired
|
||||
}
|
||||
return consts.PackageStatusEffective
|
||||
}
|
||||
|
||||
func strictIccidsFromDetails(details []*rc.BatchDetailRow) ([]string, error) {
|
||||
seen := make(map[string]struct{}, len(details))
|
||||
result := make([]string, 0, len(details))
|
||||
for _, detail := range details {
|
||||
if detail == nil {
|
||||
return nil, gerror.New("renew detail is nil")
|
||||
}
|
||||
iccid := strings.TrimSpace(detail.ItemId)
|
||||
if iccid == "" {
|
||||
return nil, gerror.New("renew detail ICCID is empty")
|
||||
}
|
||||
if _, exists := seen[iccid]; exists {
|
||||
return nil, gerror.Newf("renew detail ICCID is duplicated: %s", iccid)
|
||||
}
|
||||
seen[iccid] = struct{}{}
|
||||
result = append(result, iccid)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// uniqueIccidsFromDetails remains available to the change executor while the
|
||||
// renew path uses strictIccidsFromDetails to reject malformed input.
|
||||
func uniqueIccidsFromDetails(details []*rc.BatchDetailRow) []string {
|
||||
seen := make(map[string]struct{}, len(details))
|
||||
result := make([]string, 0, len(details))
|
||||
for _, detail := range details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(detail.ItemId)
|
||||
if iccid == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[iccid]; exists {
|
||||
continue
|
||||
}
|
||||
seen[iccid] = struct{}{}
|
||||
result = append(result, iccid)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *RenewExecutor) finalizeFailure(ctx context.Context, main *rc.BatchMainRow, reason string) error {
|
||||
now := pnow(r.nowFunc)
|
||||
contract, _ := ParseExecutionContract(main)
|
||||
if contract == nil {
|
||||
contract = renewRefundContractFromBatch(main)
|
||||
}
|
||||
nowTime := gtime.NewFromTime(now)
|
||||
err := r.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
lockedMain, err := r.repo.Tables().LockRenewBatchMain(ctx, tx, main.BatchNo, main.BatchType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if contract != nil {
|
||||
if err := refundRenewBalanceIfNeeded(ctx, tx, lockedMain, contract, reason, nowTime); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := r.repo.MarkAllDetailsFailed(ctx, tx, main.BatchNo, main.BatchType, reason, main.CreatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.repo.FinalizeBatchMain(ctx, tx, main.BatchNo, main.BatchType, &rc.BatchMainRow{
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
|
||||
RunResult: consts.BatchRunResultFailed,
|
||||
TotalCount: main.TotalCount,
|
||||
FailCount: main.TotalCount,
|
||||
FailReason: reason,
|
||||
EndTime: nowTime,
|
||||
UpdatedBy: main.CreatedBy,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "finalize renew failure failed")
|
||||
}
|
||||
snap, _ := r.repo.ResolveResultSnapshot(ctx, main.BatchNo, main.BatchType)
|
||||
_ = r.repo.WriteResultFileSafe(ctx, main.BatchNo, main.BatchType, main.TenantId, main.CreatedDept, main.CreatedBy, snap)
|
||||
g.Log().Warningf(ctx, "renew batch failed, batchNo=%s, reason=%s, tenant=%s", main.BatchNo, reason, main.TenantId)
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeBatchInfo(prev *gjson.Json, addition map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
if prev != nil {
|
||||
for key, value := range prev.Map() {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
for key, value := range addition {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pnow(fn NowFunc) time.Time {
|
||||
if fn == nil {
|
||||
return timeNow()
|
||||
}
|
||||
return fn()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
func TestDeductRuntimeRenewBalanceRejectsInsufficientBalanceAtomically(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := gdb.SetConfig(gdb.Config{
|
||||
"default": gdb.ConfigGroup{{Type: "sqlite", Name: filepath.Join(t.TempDir(), "renew-balance.db")}},
|
||||
}); err != nil {
|
||||
t.Fatalf("configure sqlite: %v", err)
|
||||
}
|
||||
defer g.DB().Close(ctx)
|
||||
|
||||
createRenewBalanceTables(t, ctx)
|
||||
if _, err := g.DB().Model(rc.TableAgentBalance).Ctx(ctx).Data(g.Map{
|
||||
"dept_id": 117,
|
||||
"tenant_id": "000000",
|
||||
"balance": 100,
|
||||
"status": renewFinanceAccountStatusNormal,
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("seed balance: %v", err)
|
||||
}
|
||||
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "REN20260806110000TEST",
|
||||
TenantId: "000000",
|
||||
CreatedBy: 60,
|
||||
}
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
_, _, _, err := deductRuntimeRenewBalance(ctx, tx, main, 117, 576, time.Date(2026, 8, 6, 11, 0, 0, 0, time.Local))
|
||||
return err
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "账户余额不足") {
|
||||
t.Fatalf("deduct error = %v, want insufficient balance", err)
|
||||
}
|
||||
|
||||
balance, err := g.DB().Model(rc.TableAgentBalance).Ctx(ctx).
|
||||
Where("dept_id", 117).
|
||||
Where("tenant_id", "000000").
|
||||
Value("balance")
|
||||
if err != nil {
|
||||
t.Fatalf("query balance: %v", err)
|
||||
}
|
||||
if got := balance.Float64(); got != 100 {
|
||||
t.Fatalf("balance = %.2f, want unchanged 100.00", got)
|
||||
}
|
||||
|
||||
count, err := g.DB().Model(rc.TableBalanceDetail).Ctx(ctx).
|
||||
Where("serial_no", "RENPAY"+main.BatchNo).
|
||||
Count()
|
||||
if err != nil {
|
||||
t.Fatalf("count balance detail: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("balance detail count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func createRenewBalanceTables(t *testing.T, ctx context.Context) {
|
||||
t.Helper()
|
||||
statements := []string{
|
||||
`CREATE TABLE t_agent_balance (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dept_id INTEGER NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
balance REAL NOT NULL,
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
updated_by INTEGER,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE t_balance_detail (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dept_id INTEGER NOT NULL,
|
||||
serial_no TEXT NOT NULL,
|
||||
transaction_time DATETIME,
|
||||
income_expense_type INTEGER,
|
||||
transaction_type INTEGER,
|
||||
transaction_channel INTEGER,
|
||||
transaction_status INTEGER,
|
||||
amount REAL,
|
||||
before_balance REAL,
|
||||
balance_after REAL,
|
||||
order_sn TEXT,
|
||||
remark TEXT,
|
||||
tenant_id TEXT NOT NULL,
|
||||
created_dept INTEGER,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME
|
||||
)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatalf("create renew balance table: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
const (
|
||||
renewFinanceAccountStatusNormal = 0
|
||||
renewFinanceIncomeExpenseExpense = 1
|
||||
)
|
||||
|
||||
type renewPaymentAccountRow struct {
|
||||
ID int64 `orm:"id"`
|
||||
Balance float64 `orm:"balance"`
|
||||
Status int `orm:"status"`
|
||||
}
|
||||
|
||||
func (r *RenewExecutor) ensureRenewExecutionContract(
|
||||
ctx context.Context,
|
||||
tx gdb.TX,
|
||||
main *rc.BatchMainRow,
|
||||
details []*rc.BatchDetailRow,
|
||||
cards map[string]*rc.SimCardInfo,
|
||||
binds map[string]*rc.SimCardBindPkg,
|
||||
futureBinds map[string][]*rc.SimCardBindPkg,
|
||||
now time.Time,
|
||||
) (*ExecutionContract, error) {
|
||||
if contract, err := ParseExecutionContract(main); err == nil {
|
||||
if err = r.validateRenewRuntimePermissions(ctx, tx, main, contract); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contract, nil
|
||||
}
|
||||
|
||||
info := main.BatchInfo
|
||||
if info == nil || info.IsNil() {
|
||||
return nil, gerror.New("续费批次缺少导入参数")
|
||||
}
|
||||
durationValue := firstInt(info, "durationValue", "durationYears")
|
||||
if durationValue <= 0 {
|
||||
durationValue = normalizeLegacyRenewDurationValue(firstInt(info, "durationMonths"))
|
||||
}
|
||||
if durationValue <= 0 {
|
||||
return nil, gerror.New("续费时长不能为空")
|
||||
}
|
||||
giftDurationMonths := firstInt(info, "giftDurationMonths")
|
||||
if giftDurationMonths < 0 {
|
||||
return nil, gerror.New("赠送时长不能小于0")
|
||||
}
|
||||
|
||||
iccids, err := strictIccidsFromDetails(details)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customerID, packageCode, beginDates, err := runtimeRenewFacts(main, details, cards, binds, futureBinds, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkg, err := r.repo.Tables().LoadRenewPackage(ctx, tx, main.TenantId, packageCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pkg.Enabled != 1 {
|
||||
return nil, gerror.New("当前套餐未启用")
|
||||
}
|
||||
durationUnit := renewDurationUnitForPackage(pkg)
|
||||
actualDurationMonths := consts.DurationValueToMonths(durationUnit, durationValue)
|
||||
actualGiftDurationMonths := consts.DurationValueToMonths(durationUnit, giftDurationMonths)
|
||||
options, err := validateRuntimeRenewDuration(pkg, durationValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
customerDept, err := loadRenewDeptTx(ctx, tx, main.TenantId, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priceSnap, err := loadRuntimeRenewPriceSnapshot(ctx, tx, main.TenantId, pkg, customerDept, options, beginDates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
amount := roundAmount(priceSnap.RenewUnitPrice * float64(durationValue) * float64(len(iccids)))
|
||||
|
||||
mode := strings.TrimSpace(firstString(info, "paymentMode", "paymentSnapshot.paymentMode"))
|
||||
payment, err := r.buildRuntimeRenewPayment(ctx, tx, main, mode, customerID, amount, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addition := map[string]any{
|
||||
"customerAgentId": customerID,
|
||||
"customerName": strings.TrimSpace(customerDept.DeptName),
|
||||
"packageCode": priceSnap.PackageCode,
|
||||
"packageName": priceSnap.PackageName,
|
||||
"priceSource": priceSnap.PriceSource,
|
||||
"priceOwnerDeptId": priceSnap.PriceOwnerDeptId,
|
||||
"agentDefaultPriceId": priceSnap.AgentDefaultPriceId,
|
||||
"targetPriceId": priceSnap.TargetPriceId,
|
||||
"renewUnitPrice": priceSnap.RenewUnitPrice,
|
||||
"renewableDurationOptions": priceSnap.RenewableDurationOptions,
|
||||
"renewablePackageCodes": priceSnap.RenewablePackageCodes,
|
||||
"renewablePackages": priceSnap.RenewablePackages,
|
||||
"billingMethod": priceSnap.BillingMethod,
|
||||
"renewDurationUnit": priceSnap.RenewDurationUnit,
|
||||
"renewDurationUnitText": priceSnap.RenewDurationUnitText,
|
||||
"renewBeginDate": priceSnap.RenewBeginDate,
|
||||
"renewBeginDateMin": priceSnap.RenewBeginDateMin,
|
||||
"renewBeginDateMax": priceSnap.RenewBeginDateMax,
|
||||
"sameRenewBeginDate": priceSnap.SameRenewBeginDate,
|
||||
"priceSnapshot": priceSnap,
|
||||
"quantity": len(iccids),
|
||||
"durationValue": durationValue,
|
||||
"durationMonths": actualDurationMonths,
|
||||
"actualDurationMonths": actualDurationMonths,
|
||||
"giftDurationMonths": giftDurationMonths,
|
||||
"actualGiftDurationMonths": actualGiftDurationMonths,
|
||||
"amount": amount,
|
||||
}
|
||||
for key, value := range payment {
|
||||
addition[key] = value
|
||||
}
|
||||
merged := mergeBatchInfo(main.BatchInfo, addition)
|
||||
main.BatchInfo = gjson.New(merged)
|
||||
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = r.validateRenewRuntimePermissions(ctx, tx, main, contract); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contract, nil
|
||||
}
|
||||
|
||||
func runtimeRenewFacts(
|
||||
main *rc.BatchMainRow,
|
||||
details []*rc.BatchDetailRow,
|
||||
cards map[string]*rc.SimCardInfo,
|
||||
binds map[string]*rc.SimCardBindPkg,
|
||||
futureBinds map[string][]*rc.SimCardBindPkg,
|
||||
now time.Time,
|
||||
) (int64, string, []string, error) {
|
||||
customerIDs := map[int64]struct{}{}
|
||||
packageCodes := map[string]struct{}{}
|
||||
beginDates := make([]string, 0, len(details))
|
||||
periodRows := make([]*RenewRow, 0, len(details))
|
||||
sourceBatchNo := executionSourceBatchNo(main, main.BatchInfo)
|
||||
|
||||
for _, detail := range details {
|
||||
if detail == nil {
|
||||
return 0, "", nil, gerror.New("续费明细为空")
|
||||
}
|
||||
iccid := strings.TrimSpace(detail.ItemId)
|
||||
card := cards[iccid]
|
||||
bind := binds[iccid]
|
||||
if card == nil {
|
||||
return 0, "", nil, gerror.Newf("ICCID不存在:%s", iccid)
|
||||
}
|
||||
if bind == nil {
|
||||
return 0, "", nil, gerror.Newf("ICCID没有订购记录:%s", iccid)
|
||||
}
|
||||
expectedMSISDN, expectedIMSI := snapshotIdentity(detail.Snapshot)
|
||||
if err := validateCurrentSubscription(card, bind, subscriptionValidation{
|
||||
ICCID: iccid,
|
||||
MSISDN: expectedMSISDN,
|
||||
IMSI: expectedIMSI,
|
||||
TenantID: main.TenantId,
|
||||
CustomerDeptID: card.DeptId,
|
||||
PackageCode: card.CurrentPackageCode,
|
||||
SourceBatchNo: sourceBatchNo,
|
||||
}); err != nil {
|
||||
return 0, "", nil, gerror.Wrapf(err, "校验续费卡失败,ICCID:%s", iccid)
|
||||
}
|
||||
base := renewBaseSubscriptionForCard(card, bind, futureBinds[iccid])
|
||||
if err := validateRenewBaseWindow(base, now, iccid); err != nil {
|
||||
return 0, "", nil, err
|
||||
}
|
||||
customerIDs[card.DeptId] = struct{}{}
|
||||
packageCodes[strings.TrimSpace(card.CurrentPackageCode)] = struct{}{}
|
||||
periodRows = append(periodRows, &RenewRow{
|
||||
RowNo: detail.RowNo,
|
||||
ICCID: iccid,
|
||||
OldEffTime: base.EffectiveTime,
|
||||
OldExpireTime: base.ExpectedExpireTime,
|
||||
})
|
||||
beginAt := renewBeginDateFromExpire(base.ExpectedExpireTime)
|
||||
if beginAt != nil {
|
||||
beginDates = append(beginDates, beginAt.Format("Y-m-d"))
|
||||
}
|
||||
}
|
||||
if err := validateRenewSameCurrentPeriod(periodRows, nil); err != nil {
|
||||
return 0, "", nil, err
|
||||
}
|
||||
if len(customerIDs) != 1 {
|
||||
return 0, "", nil, gerror.New("当前批次下存在多个客户,不能统一续费")
|
||||
}
|
||||
if len(packageCodes) != 1 {
|
||||
return 0, "", nil, gerror.New("当前批次下存在多个套餐,不能统一续费")
|
||||
}
|
||||
var customerID int64
|
||||
for id := range customerIDs {
|
||||
customerID = id
|
||||
}
|
||||
var packageCode string
|
||||
for code := range packageCodes {
|
||||
packageCode = code
|
||||
}
|
||||
return customerID, packageCode, beginDates, nil
|
||||
}
|
||||
|
||||
func normalizeLegacyRenewDurationValue(raw int) int {
|
||||
if raw <= 0 {
|
||||
return 0
|
||||
}
|
||||
if raw >= 12 && raw%12 == 0 {
|
||||
return raw / 12
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func validateRuntimeRenewDuration(pkg *rc.FlowPackageInfo, durationValue int) ([]int, error) {
|
||||
if pkg == nil {
|
||||
return nil, gerror.New("续费套餐不存在")
|
||||
}
|
||||
options, err := renewableYearOptions(pkg.RenewableDurationConfig)
|
||||
if err != nil || len(options) == 0 {
|
||||
return nil, gerror.Newf("当前套餐%s未配置可续费时长", runtimeRenewPackageLabel(pkg))
|
||||
}
|
||||
if !rc.ContainsInt(options, durationValue) {
|
||||
return nil, gerror.Newf(
|
||||
"续费时长%d%s不在当前套餐%s可续费范围内,可选:%s",
|
||||
durationValue,
|
||||
consts.RenewDurationUnitText(renewDurationUnitForPackage(pkg)),
|
||||
runtimeRenewPackageLabel(pkg),
|
||||
formatRuntimeRenewDurationOptions(options, renewDurationUnitForPackage(pkg)),
|
||||
)
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func runtimeRenewPackageLabel(pkg *rc.FlowPackageInfo) string {
|
||||
if pkg == nil {
|
||||
return ""
|
||||
}
|
||||
name := strings.TrimSpace(pkg.PackageName)
|
||||
code := strings.TrimSpace(pkg.PackageCode)
|
||||
switch {
|
||||
case name != "" && code != "":
|
||||
return name + "(" + code + ")"
|
||||
case name != "":
|
||||
return name
|
||||
default:
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
func formatRuntimeRenewDurationOptions(options []int, unit string) string {
|
||||
if len(options) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(options))
|
||||
for _, option := range options {
|
||||
parts = append(parts, fmt.Sprintf("%d%s", option, consts.RenewDurationUnitText(unit)))
|
||||
}
|
||||
return strings.Join(parts, "、")
|
||||
}
|
||||
|
||||
func snapshotIdentity(snapshot *gjson.Json) (string, string) {
|
||||
if snapshot == nil {
|
||||
return "", ""
|
||||
}
|
||||
msisdn := strings.TrimSpace(snapshot.Get("msisdn").String())
|
||||
if msisdn == "" {
|
||||
msisdn = strings.TrimSpace(snapshot.Get("MSISDN").String())
|
||||
}
|
||||
imsi := strings.TrimSpace(snapshot.Get("imsi").String())
|
||||
if imsi == "" {
|
||||
imsi = strings.TrimSpace(snapshot.Get("IMSI").String())
|
||||
}
|
||||
return msisdn, imsi
|
||||
}
|
||||
|
||||
func loadRuntimeRenewPriceSnapshot(
|
||||
ctx context.Context,
|
||||
tx gdb.TX,
|
||||
tenantID string,
|
||||
pkg *rc.FlowPackageInfo,
|
||||
customerDept *rc.SysDeptRow,
|
||||
options []int,
|
||||
beginDates []string,
|
||||
) (*RenewPriceSnapshot, error) {
|
||||
if pkg == nil || customerDept == nil {
|
||||
return nil, gerror.New("续费套餐或客户信息缺失")
|
||||
}
|
||||
priceOwnerDeptID := renewPriceOwnerDeptID(customerDept)
|
||||
salePrice, err := resolveRenewEffectiveSalePrice(ctx, renewTxEffectivePriceStore{tx: tx}, tenantID, priceOwnerDeptID, customerDept.DeptId, pkg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sameDate, minDate, maxDate, same := summarizeRenewBeginDates(beginDates)
|
||||
durationUnit := renewDurationUnitForPackage(pkg)
|
||||
renewableCodes, renewablePackages, err := runtimeRenewablePackageOptions(ctx, tx, tenantID, customerDept.DeptId, priceOwnerDeptID, pkg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RenewPriceSnapshot{
|
||||
CustomerAgentId: customerDept.DeptId,
|
||||
PriceOwnerDeptId: priceOwnerDeptID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
BillingMethod: pkg.BillingMethod,
|
||||
RenewDurationUnit: durationUnit,
|
||||
RenewDurationUnitText: consts.RenewDurationUnitText(durationUnit),
|
||||
PriceSource: salePrice.Rule.PriceSource,
|
||||
AgentDefaultPriceId: salePrice.Rule.AgentDefaultPriceId,
|
||||
TargetPriceId: salePrice.Rule.TargetPriceId,
|
||||
RenewUnitPrice: salePrice.AgentShipmentPrice,
|
||||
RenewableDurationOptions: options,
|
||||
RenewablePackageCodes: renewableCodes,
|
||||
RenewablePackages: renewablePackages,
|
||||
RenewBeginDate: sameDate,
|
||||
RenewBeginDateMin: minDate,
|
||||
RenewBeginDateMax: maxDate,
|
||||
SameRenewBeginDate: same,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func runtimeRenewablePackageOptions(ctx context.Context, tx gdb.TX, tenantID string, customerDeptID, priceOwnerDeptID int64, currentPkg *rc.FlowPackageInfo) ([]string, []RenewPackageOption, error) {
|
||||
if currentPkg == nil {
|
||||
return nil, nil, gerror.New("当前套餐不存在或未启用")
|
||||
}
|
||||
codes := renewPackageCodesFromJSON(currentPkg.RenewablePackageCodes)
|
||||
if len(codes) == 0 {
|
||||
return nil, nil, gerror.New("当前套餐未配置可续费套餐")
|
||||
}
|
||||
validCodes := make([]string, 0, len(codes))
|
||||
options := make([]RenewPackageOption, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
pkg, err := loadRenewPackageOptionTx(ctx, tx, tenantID, code)
|
||||
if err != nil || pkg == nil || pkg.Enabled != 1 {
|
||||
continue
|
||||
}
|
||||
unitPrice, err := runtimeRenewPackageUnitPrice(ctx, tx, tenantID, customerDeptID, priceOwnerDeptID, pkg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
durations, err := renewableYearOptions(pkg.RenewableDurationConfig)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
validCodes = append(validCodes, pkg.PackageCode)
|
||||
options = append(options, RenewPackageOption{
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
RenewUnitPrice: unitPrice,
|
||||
RenewableDurationOptions: durations,
|
||||
})
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return nil, nil, gerror.New("当前套餐未配置可用的可续费套餐")
|
||||
}
|
||||
return validCodes, options, nil
|
||||
}
|
||||
|
||||
func loadRenewPackageOptionTx(ctx context.Context, tx gdb.TX, tenantID, packageCode string) (*rc.FlowPackageInfo, error) {
|
||||
var pkg rc.FlowPackageInfo
|
||||
err := tx.Ctx(ctx).Model(rc.TableSimFlowPackageInfo).
|
||||
Where("tenant_id", strings.TrimSpace(tenantID)).
|
||||
Where("package_code", strings.TrimSpace(packageCode)).
|
||||
LockShared().
|
||||
Scan(&pkg)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, gerror.Wrap(err, "查询可续费套餐失败")
|
||||
}
|
||||
if pkg.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
func runtimeRenewPackageUnitPrice(ctx context.Context, tx gdb.TX, tenantID string, customerDeptID, priceOwnerDeptID int64, pkg *rc.FlowPackageInfo) (float64, error) {
|
||||
if pkg == nil {
|
||||
return 0, gerror.New("续费套餐不存在")
|
||||
}
|
||||
salePrice, err := resolveRenewEffectiveSalePrice(ctx, renewTxEffectivePriceStore{tx: tx}, tenantID, priceOwnerDeptID, customerDeptID, pkg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return salePrice.AgentShipmentPrice, nil
|
||||
}
|
||||
|
||||
func (r *RenewExecutor) buildRuntimeRenewPayment(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow, requestedMode string, payerDeptID int64, amount float64, now time.Time) (map[string]any, error) {
|
||||
isTop, operatorDeptID, err := isRenewTopDeptTx(ctx, tx, main)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payerDeptID <= 0 {
|
||||
payerDeptID = operatorDeptID
|
||||
}
|
||||
mode := strings.TrimSpace(requestedMode)
|
||||
if mode == "" {
|
||||
if isTop {
|
||||
mode = "offline"
|
||||
} else {
|
||||
mode = "balance"
|
||||
}
|
||||
}
|
||||
switch mode {
|
||||
case "offline":
|
||||
if !isTop {
|
||||
return nil, gerror.New("当前组织续费必须使用余额支付")
|
||||
}
|
||||
if main.BatchInfo != nil && !main.BatchInfo.Get("offlineContractCompleted").Bool() {
|
||||
return nil, gerror.New("请确认线下业务已完成")
|
||||
}
|
||||
return runtimePaymentSnapshot(mode, consts.PayTypeOffline, amount, 0, amount, 0, 0, "", payerDeptID, main.CreatedBy, isTop, now), nil
|
||||
case "balance":
|
||||
before, after, serialNo, err := deductRuntimeRenewBalance(ctx, tx, main, payerDeptID, amount, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return runtimePaymentSnapshot(mode, consts.PayTypeBalance, amount, amount, 0, before, after, serialNo, payerDeptID, main.CreatedBy, isTop, now), nil
|
||||
default:
|
||||
return nil, gerror.New("续费付款方式不正确")
|
||||
}
|
||||
}
|
||||
|
||||
func runtimePaymentSnapshot(mode string, payType int, payAmount, balanceAmount, offlineAmount, before, after float64, serialNo string, payerDeptID, payerUserID int64, isTop bool, now time.Time) map[string]any {
|
||||
paymentTime := now.Format("2006-01-02 15:04:05")
|
||||
snapshot := map[string]any{
|
||||
"paymentMode": mode,
|
||||
"payType": payType,
|
||||
"payStatus": consts.PayStatusPaid,
|
||||
"payAmount": payAmount,
|
||||
"balanceAmount": balanceAmount,
|
||||
"offlinePaidAmount": offlineAmount,
|
||||
"balanceBefore": before,
|
||||
"balanceAfter": after,
|
||||
"balanceDetailSerialNo": strings.TrimSpace(serialNo),
|
||||
"paymentTime": paymentTime,
|
||||
"payerDeptId": payerDeptID,
|
||||
"payerUserId": payerUserID,
|
||||
"isTopDept": isTop,
|
||||
}
|
||||
return map[string]any{
|
||||
"paymentMode": mode,
|
||||
"payType": payType,
|
||||
"payStatus": consts.PayStatusPaid,
|
||||
"payAmount": payAmount,
|
||||
"balanceAmount": balanceAmount,
|
||||
"offlinePaidAmount": offlineAmount,
|
||||
"balanceBefore": before,
|
||||
"balanceAfter": after,
|
||||
"balanceDetailSerialNo": strings.TrimSpace(serialNo),
|
||||
"paymentTime": paymentTime,
|
||||
"payerDeptId": payerDeptID,
|
||||
"payerUserId": payerUserID,
|
||||
"isTopDept": isTop,
|
||||
"paymentSnapshot": snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
func deductRuntimeRenewBalance(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow, payerDeptID int64, amount float64, now time.Time) (float64, float64, string, error) {
|
||||
amount = roundAmount(amount)
|
||||
if amount <= 0 {
|
||||
return 0, 0, "", nil
|
||||
}
|
||||
serialNo := "RENPAY" + strings.TrimSpace(main.BatchNo)
|
||||
if serialNo == "RENPAY" {
|
||||
return 0, 0, "", gerror.New("续费余额流水号生成失败")
|
||||
}
|
||||
existingModel := tx.Ctx(ctx).Model(rc.TableBalanceDetail).
|
||||
Where("tenant_id", main.TenantId).
|
||||
Where("serial_no", serialNo)
|
||||
existing, err := runtimeRenewLockForUpdate(existingModel).One()
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, 0, "", gerror.Wrap(err, "查询续费余额流水失败")
|
||||
}
|
||||
if existing != nil && !existing.IsEmpty() {
|
||||
return 0, 0, "", gerror.New("当前续费批次已扣款,请勿重复提交")
|
||||
}
|
||||
|
||||
var account renewPaymentAccountRow
|
||||
recordModel := tx.Ctx(ctx).Model(rc.TableAgentBalance).
|
||||
Where("dept_id", payerDeptID).
|
||||
Where("tenant_id", main.TenantId)
|
||||
record, err := runtimeRenewLockForUpdate(recordModel).One()
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, 0, "", gerror.Wrap(err, "查询账户余额失败")
|
||||
}
|
||||
if record != nil && !record.IsEmpty() {
|
||||
if err := record.Struct(&account); err != nil {
|
||||
return 0, 0, "", gerror.Wrap(err, "解析账户余额失败")
|
||||
}
|
||||
}
|
||||
if account.ID == 0 {
|
||||
return 0, 0, "", gerror.New("账户余额不存在,请先充值")
|
||||
}
|
||||
if account.Status != renewFinanceAccountStatusNormal {
|
||||
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)
|
||||
nowTime := gtime.NewFromTime(now)
|
||||
if _, err := tx.Ctx(ctx).Model(rc.TableAgentBalance).Where("id", account.ID).Data(g.Map{
|
||||
"balance": after,
|
||||
"updated_by": main.CreatedBy,
|
||||
"updated_at": nowTime,
|
||||
}).Update(); err != nil {
|
||||
return 0, 0, "", gerror.Wrap(err, "扣减账户余额失败")
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(rc.TableBalanceDetail).Data(g.Map{
|
||||
"dept_id": payerDeptID,
|
||||
"serial_no": serialNo,
|
||||
"transaction_time": nowTime,
|
||||
"income_expense_type": renewFinanceIncomeExpenseExpense,
|
||||
"transaction_type": renewFinanceTransactionTypeConsume,
|
||||
"transaction_channel": renewFinanceTransactionChannelBalance,
|
||||
"transaction_status": renewFinanceTransactionStatusPaid,
|
||||
"amount": amount,
|
||||
"before_balance": before,
|
||||
"balance_after": after,
|
||||
"order_sn": main.BatchNo,
|
||||
"remark": fmt.Sprintf("SIM卡续费余额支付,批次号:%s", main.BatchNo),
|
||||
"tenant_id": main.TenantId,
|
||||
"created_dept": payerDeptID,
|
||||
"created_by": main.CreatedBy,
|
||||
"created_at": nowTime,
|
||||
}).Insert(); err != nil {
|
||||
return 0, 0, "", gerror.Wrap(err, "写入续费余额流水失败")
|
||||
}
|
||||
return before, after, serialNo, nil
|
||||
}
|
||||
|
||||
func runtimeRenewLockForUpdate(model *gdb.Model) *gdb.Model {
|
||||
if g.DB().GetConfig().Type == "sqlite" {
|
||||
return model
|
||||
}
|
||||
return model.LockUpdate()
|
||||
}
|
||||
|
||||
func (r *RenewExecutor) validateRenewRuntimePermissions(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow, contract *ExecutionContract) error {
|
||||
if contract == nil {
|
||||
return gerror.New("续费执行合同不能为空")
|
||||
}
|
||||
isTop, _, err := isRenewTopDeptTx(ctx, tx, main)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if contract.PaymentMode == "offline" && !isTop {
|
||||
return gerror.New("当前组织续费必须使用余额支付")
|
||||
}
|
||||
if contract.PackageBeginTime != nil && !isTop {
|
||||
return gerror.New("当前组织不能修改续费日期")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isRenewTopDeptTx(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow) (bool, int64, error) {
|
||||
deptID := main.CreatedDept
|
||||
if deptID <= 0 {
|
||||
deptID = main.DeptId
|
||||
}
|
||||
if deptID <= 0 {
|
||||
return false, 0, gerror.New("当前批次缺少操作组织")
|
||||
}
|
||||
dept, err := loadRenewDeptTx(ctx, tx, main.TenantId, deptID)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
return dept.ParentId == 0, deptID, nil
|
||||
}
|
||||
|
||||
func loadRenewDeptTx(ctx context.Context, tx gdb.TX, tenantID string, deptID int64) (*rc.SysDeptRow, error) {
|
||||
var row rc.SysDeptRow
|
||||
err := tx.Ctx(ctx).Model(rc.TableSysDept).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("dept_id", deptID).
|
||||
WhereNull("deleted_at").
|
||||
LockShared().
|
||||
Scan(&row)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询客户部门失败")
|
||||
}
|
||||
if row.DeptId == 0 {
|
||||
return nil, gerror.New("客户部门不存在")
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func loadRenewPriceOverrideTx(ctx context.Context, tx gdb.TX, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*rc.FlowPackagePrice, error) {
|
||||
var row rc.FlowPackagePrice
|
||||
err := tx.Ctx(ctx).Model(rc.TableSimFlowPackagePrice).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("dept_id", deptID).
|
||||
Where("agent_scope_type", scopeType).
|
||||
Where("target_agent_id", targetAgentID).
|
||||
Where("package_code", strings.TrimSpace(packageCode)).
|
||||
Where("enabled", 1).
|
||||
LockShared().
|
||||
Scan(&row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, gerror.Wrap(err, "查询代理续费定价失败")
|
||||
}
|
||||
if row.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
const (
|
||||
renewFinanceIncomeExpenseIncome = 0
|
||||
renewFinanceTransactionTypeConsume = 1
|
||||
renewFinanceTransactionChannelBalance = 3
|
||||
renewFinanceTransactionStatusPaid = 1
|
||||
)
|
||||
|
||||
type renewBalanceAccountRow struct {
|
||||
ID int64 `orm:"id"`
|
||||
Balance float64 `orm:"balance"`
|
||||
}
|
||||
|
||||
type renewBalanceDetailRow struct {
|
||||
ID int64 `orm:"id"`
|
||||
DeptID int64 `orm:"dept_id"`
|
||||
SerialNo string `orm:"serial_no"`
|
||||
Amount float64 `orm:"amount"`
|
||||
TransactionStatus int `orm:"transaction_status"`
|
||||
TenantID string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
}
|
||||
|
||||
func refundRenewBalanceIfNeeded(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow, contract *ExecutionContract, reason string, now *gtime.Time) error {
|
||||
if main == nil || contract == nil {
|
||||
return nil
|
||||
}
|
||||
if contract.PaymentMode != "balance" || strings.TrimSpace(contract.BalanceDetailSerialNo) == "" || contract.BalanceAmount <= 0 {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(main.OrderSn) != "" {
|
||||
return nil
|
||||
}
|
||||
refundSerialNo := "REFUND" + strings.TrimSpace(contract.BalanceDetailSerialNo)
|
||||
var existing renewBalanceDetailRow
|
||||
existingRecord, err := tx.Ctx(ctx).Model(rc.TableBalanceDetail).
|
||||
Where("tenant_id", contract.TenantID).
|
||||
Where("serial_no", refundSerialNo).
|
||||
LockUpdate().
|
||||
One()
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return gerror.Wrap(err, "query renew refund detail failed")
|
||||
}
|
||||
if existingRecord != nil && !existingRecord.IsEmpty() {
|
||||
if err := existingRecord.Struct(&existing); err != nil {
|
||||
return gerror.Wrap(err, "decode renew refund detail failed")
|
||||
}
|
||||
}
|
||||
if existing.ID != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var original renewBalanceDetailRow
|
||||
originalRecord, err := tx.Ctx(ctx).Model(rc.TableBalanceDetail).
|
||||
Where("tenant_id", contract.TenantID).
|
||||
Where("serial_no", strings.TrimSpace(contract.BalanceDetailSerialNo)).
|
||||
LockUpdate().
|
||||
One()
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return gerror.Wrap(err, "query renew payment detail failed")
|
||||
}
|
||||
if originalRecord != nil && !originalRecord.IsEmpty() {
|
||||
if err := originalRecord.Struct(&original); err != nil {
|
||||
return gerror.Wrap(err, "decode renew payment detail failed")
|
||||
}
|
||||
}
|
||||
if original.ID == 0 {
|
||||
g.Log().Warningf(ctx, "renew balance refund skipped, original payment detail missing, batchNo=%s serialNo=%s",
|
||||
main.BatchNo, contract.BalanceDetailSerialNo)
|
||||
return nil
|
||||
}
|
||||
|
||||
payerDeptID := contract.PayerDeptID
|
||||
if payerDeptID <= 0 {
|
||||
payerDeptID = original.DeptID
|
||||
}
|
||||
var account renewBalanceAccountRow
|
||||
accountRecord, err := tx.Ctx(ctx).Model(rc.TableAgentBalance).
|
||||
Where("dept_id", payerDeptID).
|
||||
Where("tenant_id", contract.TenantID).
|
||||
LockUpdate().
|
||||
One()
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return gerror.Wrap(err, "lock renew refund account failed")
|
||||
}
|
||||
if accountRecord != nil && !accountRecord.IsEmpty() {
|
||||
if err := accountRecord.Struct(&account); err != nil {
|
||||
return gerror.Wrap(err, "decode renew refund account failed")
|
||||
}
|
||||
}
|
||||
if account.ID == 0 {
|
||||
return gerror.New("renew refund account missing")
|
||||
}
|
||||
if now == nil {
|
||||
now = gtime.Now()
|
||||
}
|
||||
amount := roundAmount(original.Amount)
|
||||
before := roundAmount(account.Balance)
|
||||
after := roundAmount(before + amount)
|
||||
if _, err := tx.Ctx(ctx).Model(rc.TableAgentBalance).Where("id", account.ID).Data(g.Map{
|
||||
"balance": after,
|
||||
"updated_by": contract.OperatorUserID,
|
||||
"updated_at": now,
|
||||
}).Update(); err != nil {
|
||||
return gerror.Wrap(err, "refund renew balance failed")
|
||||
}
|
||||
remark := fmt.Sprintf("SIM卡续费批次失败退款,批次号:%s,原因:%s", main.BatchNo, strings.TrimSpace(reason))
|
||||
if _, err := tx.Ctx(ctx).Model(rc.TableBalanceDetail).Data(g.Map{
|
||||
"dept_id": payerDeptID,
|
||||
"serial_no": refundSerialNo,
|
||||
"transaction_time": now,
|
||||
"income_expense_type": renewFinanceIncomeExpenseIncome,
|
||||
"transaction_type": renewFinanceTransactionTypeConsume,
|
||||
"transaction_channel": renewFinanceTransactionChannelBalance,
|
||||
"transaction_status": renewFinanceTransactionStatusPaid,
|
||||
"amount": amount,
|
||||
"before_balance": before,
|
||||
"balance_after": after,
|
||||
"order_sn": main.BatchNo,
|
||||
"remark": remark,
|
||||
"tenant_id": contract.TenantID,
|
||||
"created_dept": original.CreatedDept,
|
||||
"created_by": original.CreatedBy,
|
||||
"created_at": now,
|
||||
}).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "insert renew refund detail failed")
|
||||
}
|
||||
g.Log().Warningf(ctx, "renew balance refunded, batchNo=%s serialNo=%s refundSerialNo=%s amount=%.2f reason=%s",
|
||||
main.BatchNo, contract.BalanceDetailSerialNo, refundSerialNo, amount, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
func renewRefundContractFromBatch(main *rc.BatchMainRow) *ExecutionContract {
|
||||
if main == nil || main.BatchInfo == nil || main.BatchInfo.IsNil() {
|
||||
return nil
|
||||
}
|
||||
info := main.BatchInfo
|
||||
contract := &ExecutionContract{
|
||||
BatchNo: strings.TrimSpace(main.BatchNo),
|
||||
BatchType: main.BatchType,
|
||||
TenantID: strings.TrimSpace(main.TenantId),
|
||||
CustomerAgentID: firstInt64(info, "customerAgentId", "priceSnapshot.customerAgentId"),
|
||||
SellerAgentID: main.DeptId,
|
||||
CreatedDeptID: main.CreatedDept,
|
||||
OperatorUserID: main.CreatedBy,
|
||||
PaymentMode: firstString(info, "paymentMode", "paymentSnapshot.paymentMode"),
|
||||
PayType: firstInt(info, "payType", "paymentSnapshot.payType"),
|
||||
PayAmount: firstAmount(info, "payAmount", "paymentSnapshot.payAmount"),
|
||||
BalanceAmount: firstAmount(info, "balanceAmount", "paymentSnapshot.balanceAmount"),
|
||||
BalanceDetailSerialNo: firstString(info, "balanceDetailSerialNo", "paymentSnapshot.balanceDetailSerialNo"),
|
||||
PayerDeptID: firstInt64(info, "payerDeptId", "paymentSnapshot.payerDeptId"),
|
||||
PayerUserID: firstInt64(info, "payerUserId", "paymentSnapshot.payerUserId"),
|
||||
}
|
||||
contract.normalizePaymentContract()
|
||||
if contract.PaymentMode != "balance" || strings.TrimSpace(contract.BalanceDetailSerialNo) == "" || contract.BalanceAmount <= 0 {
|
||||
return nil
|
||||
}
|
||||
return contract
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
func TestValidateRuntimeRenewDurationRejectsUnsupportedOption(t *testing.T) {
|
||||
pkg := &rc.FlowPackageInfo{
|
||||
PackageCode: "PKG001",
|
||||
BillingMethod: consts.BillingMethodMonth,
|
||||
PackageName: "测试套餐",
|
||||
RenewableDurationConfig: gjson.New([]any{1, 2, 3}),
|
||||
}
|
||||
|
||||
options, err := validateRuntimeRenewDuration(pkg, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRuntimeRenewDuration returned error: %v", err)
|
||||
}
|
||||
if len(options) != 3 {
|
||||
t.Fatalf("options = %v, want 3 entries", options)
|
||||
}
|
||||
|
||||
_, err = validateRuntimeRenewDuration(pkg, 4)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported renew duration to be rejected")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "4年") || !strings.Contains(got, "测试套餐(PKG001)") || !strings.Contains(got, "3年") {
|
||||
t.Fatalf("error message %q does not include duration/package/options", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRuntimeRenewDurationNormalizesLegacyMonthOptions(t *testing.T) {
|
||||
pkg := &rc.FlowPackageInfo{
|
||||
PackageCode: "PKG001",
|
||||
PackageName: "测试套餐",
|
||||
RenewableDurationConfig: gjson.New([]any{12, 24, 36}),
|
||||
}
|
||||
|
||||
options, err := validateRuntimeRenewDuration(pkg, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRuntimeRenewDuration returned error: %v", err)
|
||||
}
|
||||
if got := options; len(got) != 3 || got[0] != 1 || got[1] != 2 || got[2] != 3 {
|
||||
t.Fatalf("options = %v, want [1 2 3]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMonthsClampsToTargetMonthEnd(t *testing.T) {
|
||||
begin := time.Date(2027, time.January, 31, 0, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2027, time.February, 28, 0, 0, 0, 0, time.UTC)
|
||||
if got := addMonths(begin, 1); !got.Equal(want) {
|
||||
t.Fatalf("addMonths() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewBaseSubscriptionForCardPrefersLaterCardExpire(t *testing.T) {
|
||||
card := &rc.SimCardInfo{
|
||||
Iccid: "8986001",
|
||||
CurrentPackageCode: "PKG001",
|
||||
FlowPkgEndAt: gtime.NewFromStr("2026-08-31 00:00:00"),
|
||||
FlowPkgPresentAt: gtime.NewFromStr("2026-09-30 00:00:00"),
|
||||
FlowPkgRealPresent: gtime.NewFromStr("2026-10-31 00:00:00"),
|
||||
}
|
||||
bind := &rc.SimCardBindPkg{
|
||||
Iccid: "8986001",
|
||||
PackageCode: "PKG001",
|
||||
ExpectedExpireTime: gtime.NewFromStr("2026-08-31 00:00:00"),
|
||||
}
|
||||
|
||||
if got := renewBaseSubscriptionForCard(card, bind, nil); got == nil || got.ExpectedExpireTime.Format("Y-m-d") != "2026-10-31" {
|
||||
t.Fatalf("renew base expire = %v, want 2026-10-31", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecutionContractConvertsYearDurationToActualMonths(t *testing.T) {
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "REN20260806000000TEST",
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
TenantId: "000000",
|
||||
DeptId: 1000,
|
||||
CreatedDept: 1000,
|
||||
CreatedBy: 1,
|
||||
BatchInfo: gjson.New(map[string]any{
|
||||
"customerAgentId": 1001,
|
||||
"packageCode": "PKG001",
|
||||
"packageName": "Test package",
|
||||
"renewUnitPrice": 120,
|
||||
"quantity": 2,
|
||||
"billingMethod": consts.BillingMethodYear,
|
||||
"renewDurationUnit": consts.RenewDurationUnitYear,
|
||||
"durationValue": 1,
|
||||
"durationMonths": 1,
|
||||
"offlineContractCompleted": true,
|
||||
"paymentMode": "offline",
|
||||
}),
|
||||
}
|
||||
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExecutionContract returned error: %v", err)
|
||||
}
|
||||
if contract.DurationDisplayValue != 1 {
|
||||
t.Fatalf("display duration = %d, want 1", contract.DurationDisplayValue)
|
||||
}
|
||||
if contract.DurationMonths != 12 {
|
||||
t.Fatalf("actual duration months = %d, want 12", contract.DurationMonths)
|
||||
}
|
||||
if got := contract.TotalAmount(); got != 240 {
|
||||
t.Fatalf("total amount = %v, want 240", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRenewFactsRequiresSameCurrentPeriod(t *testing.T) {
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "REN20260806000000TEST",
|
||||
TenantId: "000000",
|
||||
}
|
||||
begin := gtime.NewFromStr("2026-01-01 00:00:00")
|
||||
expire := gtime.NewFromStr("2026-12-31 23:59:59")
|
||||
details := []*rc.BatchDetailRow{
|
||||
{RowNo: 1, ItemId: "898600000000000001"},
|
||||
{RowNo: 2, ItemId: "898600000000000002"},
|
||||
}
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
details[0].ItemId: {
|
||||
Iccid: details[0].ItemId,
|
||||
DeptId: 117,
|
||||
CurrentPackageCode: "PKG001",
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
TenantId: main.TenantId,
|
||||
},
|
||||
details[1].ItemId: {
|
||||
Iccid: details[1].ItemId,
|
||||
DeptId: 117,
|
||||
CurrentPackageCode: "PKG001",
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
TenantId: main.TenantId,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
details[0].ItemId: {
|
||||
Iccid: details[0].ItemId,
|
||||
DeptId: 117,
|
||||
PackageCode: "PKG001",
|
||||
EffectiveTime: begin,
|
||||
ExpectedExpireTime: expire,
|
||||
TenantId: main.TenantId,
|
||||
},
|
||||
details[1].ItemId: {
|
||||
Iccid: details[1].ItemId,
|
||||
DeptId: 117,
|
||||
PackageCode: "PKG001",
|
||||
EffectiveTime: begin,
|
||||
ExpectedExpireTime: expire,
|
||||
TenantId: main.TenantId,
|
||||
},
|
||||
}
|
||||
|
||||
now := time.Date(2026, 8, 1, 0, 0, 0, 0, time.Local)
|
||||
if _, _, _, err := runtimeRenewFacts(main, details, cards, binds, nil, now); err != nil {
|
||||
t.Fatalf("runtimeRenewFacts() error = %v", err)
|
||||
}
|
||||
|
||||
binds[details[1].ItemId].ExpectedExpireTime = gtime.NewFromStr("2027-01-31 23:59:59")
|
||||
if _, _, _, err := runtimeRenewFacts(main, details, cards, binds, nil, now); err == nil {
|
||||
t.Fatalf("runtimeRenewFacts() error = nil, want same period failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewExecutionsUsesConfirmedDuration(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
base := now.AddDate(0, 1, 0)
|
||||
contract := testRenewContract(12, 2)
|
||||
details := []*rc.BatchDetailRow{{RowNo: 1, ItemId: "8986000000000000001"}}
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
details[0].ItemId: {
|
||||
Iccid: details[0].ItemId,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
CurrentPackageCode: contract.PackageCode,
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
FlowPkgBeginAt: gtime.NewFromTime(now.AddDate(0, -1, 0)),
|
||||
FlowPkgEndAt: gtime.NewFromTime(base),
|
||||
OutboundBatchNo: contract.SourceBatchNo,
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
details[0].ItemId: {
|
||||
Iccid: details[0].ItemId,
|
||||
BatchNo: contract.SourceBatchNo,
|
||||
Msisdn: cards[details[0].ItemId].Msisdn,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(base),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := validateRenewExecutions(contract, details, cards, binds, nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRenewExecutions returned error: %v", err)
|
||||
}
|
||||
renewBegin := renewBeginDateFromExpire(gtime.NewFromTime(base))
|
||||
wantEnd := addMonths(renewBegin.Time, 14)
|
||||
if len(got) != 1 || !got[0].end.Equal(wantEnd) {
|
||||
t.Fatalf("renew end = %v, want %v", got[0].end, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewExecutionsUsesLatestBindExpiration(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
contract := testRenewContract(12, 0)
|
||||
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000002"}
|
||||
cardEnd := gtime.NewFromTime(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
CurrentPackageCode: contract.PackageCode,
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
FlowPkgEndAt: cardEnd,
|
||||
OutboundBatchNo: contract.SourceBatchNo,
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: contract.SourceBatchNo,
|
||||
Msisdn: cards[detail.ItemId].Msisdn,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(cardEnd.Time.AddDate(0, 0, 1)),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRenewExecutions returned error: %v", err)
|
||||
}
|
||||
renewBegin := renewBeginDateFromExpire(gtime.NewFromTime(cardEnd.Time.AddDate(0, 0, 1)))
|
||||
wantEnd := addMonths(renewBegin.Time, contract.DurationMonths)
|
||||
if len(got) != 1 || !got[0].end.Equal(wantEnd) {
|
||||
t.Fatalf("renew end = %v, want %v", got[0].end, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewExecutionsAllowsRenewalFromExpiredSubscription(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
contract := testRenewContract(12, 0)
|
||||
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000006"}
|
||||
expiredAt := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
CurrentPackageCode: contract.PackageCode,
|
||||
PackageStatus: consts.PackageStatusExpired,
|
||||
OutboundBatchNo: contract.SourceBatchNo,
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: contract.SourceBatchNo,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
EffectiveStatus: consts.EffectiveStatusExpired,
|
||||
ExpectedExpireTime: gtime.NewFromTime(expiredAt),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRenewExecutions returned error: %v", err)
|
||||
}
|
||||
renewBegin := renewBeginDateFromExpire(gtime.NewFromTime(expiredAt))
|
||||
wantEnd := addMonths(renewBegin.Time, contract.DurationMonths)
|
||||
if len(got) != 1 || !got[0].begin.Equal(renewBegin.Time) || !got[0].end.Equal(wantEnd) {
|
||||
t.Fatalf("renew range = %v - %v, want %v - %v", got[0].begin, got[0].end, renewBegin.Time, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewExecutionsAppendsAfterFuturePendingExpiration(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
contract := testRenewContract(12, 0)
|
||||
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000005"}
|
||||
currentExpire := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
futureExpire := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
CurrentPackageCode: contract.PackageCode,
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
OutboundBatchNo: contract.SourceBatchNo,
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: contract.SourceBatchNo,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(currentExpire),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
futureBinds := map[string][]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
{
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: "REN-FUTURE",
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(futureExpire),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, futureBinds, now)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRenewExecutions returned error: %v", err)
|
||||
}
|
||||
renewBegin := renewBeginDateFromExpire(gtime.NewFromTime(futureExpire))
|
||||
wantEnd := addMonths(renewBegin.Time, contract.DurationMonths)
|
||||
if len(got) != 1 || !got[0].begin.Equal(renewBegin.Time) || !got[0].end.Equal(wantEnd) {
|
||||
t.Fatalf("renew range = %v - %v, want %v - %v", got[0].begin, got[0].end, renewBegin.Time, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewExecutionsRejectsPendingExpirationBeyondWindow(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
contract := testRenewContract(12, 0)
|
||||
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000007"}
|
||||
currentExpire := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
futureExpire := time.Date(2027, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
CurrentPackageCode: contract.PackageCode,
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
OutboundBatchNo: contract.SourceBatchNo,
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: contract.SourceBatchNo,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(currentExpire),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
futureBinds := map[string][]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
{
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: "REN-FUTURE",
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(futureExpire),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, futureBinds, now)
|
||||
if err == nil || !strings.Contains(err.Error(), renewExpireFailureReason) {
|
||||
t.Fatalf("validateRenewExecutions error = %v, want renew window failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunningRenewDetailsRejectsChangedSnapshot(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
locked []*rc.BatchDetailRow
|
||||
loaded []*rc.BatchDetailRow
|
||||
}{
|
||||
{
|
||||
name: "count",
|
||||
locked: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
loaded: nil,
|
||||
},
|
||||
{
|
||||
name: "id",
|
||||
locked: []*rc.BatchDetailRow{{Id: 2, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
loaded: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
},
|
||||
{
|
||||
name: "row number",
|
||||
locked: []*rc.BatchDetailRow{{Id: 1, RowNo: 2, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
loaded: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
},
|
||||
{
|
||||
name: "iccid",
|
||||
locked: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986002", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
loaded: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
locked: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusSuccess}},
|
||||
loaded: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := validateRunningRenewDetails(tc.locked, tc.loaded); err == nil {
|
||||
t.Fatal("expected changed renew details to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewExecutionsUsesConfirmedPackageBeginTime(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.Local)
|
||||
contract := testRenewContract(12, 0)
|
||||
confirmedBegin := time.Date(2026, 10, 1, 0, 0, 0, 0, time.Local)
|
||||
contract.PackageBeginTime = gtime.NewFromTime(confirmedBegin)
|
||||
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000004"}
|
||||
latestExpire := time.Date(2026, 9, 1, 0, 0, 0, 0, time.Local)
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
CurrentPackageCode: contract.PackageCode,
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
OutboundBatchNo: contract.SourceBatchNo,
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: contract.SourceBatchNo,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(latestExpire),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRenewExecutions returned error: %v", err)
|
||||
}
|
||||
wantEnd := addMonths(confirmedBegin, contract.DurationMonths)
|
||||
if len(got) != 1 || !got[0].begin.Equal(confirmedBegin) || !got[0].end.Equal(wantEnd) {
|
||||
t.Fatalf("renew range = %v - %v, want %v - %v", got[0].begin, got[0].end, confirmedBegin, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRenewBindUsesConfirmedContract(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
begin := now.AddDate(0, 1, 0)
|
||||
contract := testRenewContract(12, 3)
|
||||
pkg := &rc.FlowPackageInfo{PackageCode: contract.PackageCode, PackageType: 7}
|
||||
execution := renewExecution{
|
||||
card: &rc.SimCardInfo{Iccid: "8986000000000000003", Msisdn: "13800000000"},
|
||||
bind: &rc.SimCardBindPkg{ExpectedExpireTime: gtime.NewFromTime(begin)},
|
||||
begin: begin,
|
||||
end: contract.ServiceEnd(begin),
|
||||
}
|
||||
|
||||
bind := buildRenewBind(contract, pkg, execution, "SO20260803100000ABC", "SUB20260803100000ABC", now)
|
||||
if bind.DurationMonths != 12 || bind.GiftDurationMonths != 3 {
|
||||
t.Fatalf("duration = %d+%d, want 12+3", bind.DurationMonths, bind.GiftDurationMonths)
|
||||
}
|
||||
if bind.PackageType != 7 || bind.EffectiveTime.Time != begin {
|
||||
t.Fatalf("bind contract fields were not preserved: %+v", bind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRenewDetailSnapshotUsesConfirmedBatchAmount(t *testing.T) {
|
||||
contract := testRenewContract(12, 0)
|
||||
contract.Quantity = 2
|
||||
execution := renewExecution{
|
||||
detail: &rc.BatchDetailRow{RowNo: 1},
|
||||
card: &rc.SimCardInfo{Iccid: "8986000000000000003"},
|
||||
bind: &rc.SimCardBindPkg{},
|
||||
begin: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC),
|
||||
end: time.Date(2027, 8, 3, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
pkg := &rc.FlowPackageInfo{PackageCode: contract.PackageCode, PackageName: contract.PackageName}
|
||||
|
||||
snapshot := buildRenewDetailSnapshot(execution, contract, pkg, "SO-1", "SUB-1")
|
||||
if got := snapshot.Get("amount").Float64(); got != 240 {
|
||||
t.Fatalf("detail amount = %v, want confirmed batch amount 240", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewPackageStatus(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
if got := renewPackageStatus(now, gtime.NewFromTime(now.AddDate(0, 1, 0)), gtime.NewFromTime(now.AddDate(0, 13, 0))); got != consts.PackageStatusPending {
|
||||
t.Fatalf("future package status = %d, want pending", got)
|
||||
}
|
||||
if got := renewPackageStatus(now, gtime.NewFromTime(now.AddDate(0, -1, 0)), gtime.NewFromTime(now.AddDate(0, 1, 0))); got != consts.PackageStatusEffective {
|
||||
t.Fatalf("active package status = %d, want effective", got)
|
||||
}
|
||||
if got := renewPackageStatus(now, gtime.NewFromTime(now.AddDate(0, -2, 0)), gtime.NewFromTime(now.AddDate(0, -1, 0))); got != consts.PackageStatusExpired {
|
||||
t.Fatalf("expired package status = %d, want expired", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testRenewContract(duration, gift int) *ExecutionContract {
|
||||
return &ExecutionContract{
|
||||
BatchNo: "REN20260803100000TEST",
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
SourceBatchNo: "OUT20260801TEST",
|
||||
TenantID: "000000",
|
||||
CustomerAgentID: 1001,
|
||||
SellerAgentID: 1000,
|
||||
CreatedDeptID: 1000,
|
||||
OperatorUserID: 1,
|
||||
PackageCode: "PKG001",
|
||||
PackageName: "Test package",
|
||||
PriceSource: PriceSourceSystem,
|
||||
UnitPrice: 10,
|
||||
Quantity: 1,
|
||||
DurationMonths: duration,
|
||||
GiftDurationMonths: gift,
|
||||
OfflineContractCompleted: true,
|
||||
BuyerNote: "buyer",
|
||||
AdminNote: "admin",
|
||||
Remark: "renew test",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
batchrepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/batch"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
// Repository orchestrates table access + batch status writes for renew/change
|
||||
// service. It embeds the typed table repository and adds batch lifecycle
|
||||
// helpers that align with the Admin side batch_main/batch_detail contract.
|
||||
type Repository struct {
|
||||
tables *rc.Repository
|
||||
batchRepo *batchrepo.Repository
|
||||
results batchresult.Generator
|
||||
}
|
||||
|
||||
// NewRepository builds a service-level repository backed by the typed table
|
||||
// repository (g.DB() default) and the shared batch repository.
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{
|
||||
tables: rc.New(),
|
||||
batchRepo: batchrepo.New(),
|
||||
results: batchresult.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// newRepositoryWithDeps allows tests to inject custom dependencies.
|
||||
func newRepositoryWithDeps(tables *rc.Repository, batchRepo *batchrepo.Repository, results batchresult.Generator) *Repository {
|
||||
return &Repository{tables: tables, batchRepo: batchRepo, results: results}
|
||||
}
|
||||
|
||||
// Tables exposes the low-level repository for tests.
|
||||
func (r *Repository) Tables() *rc.Repository { return r.tables }
|
||||
|
||||
// Batch exposes the batch repository.
|
||||
func (r *Repository) Batch() *batchrepo.Repository { return r.batchRepo }
|
||||
|
||||
// Results exposes the result file generator.
|
||||
func (r *Repository) Results() batchresult.Generator { return r.results }
|
||||
|
||||
// DB exposes the underlying gdb.DB handle.
|
||||
func (r *Repository) DB() gdb.DB { return r.tables.DB() }
|
||||
|
||||
// LoadProcessingBatch loads a processing renew/change/prepare batch by
|
||||
// (batchNo, batchType). It returns an error if the batch is not in
|
||||
// processing state so the caller can log and skip safely.
|
||||
func (r *Repository) LoadProcessingBatch(ctx context.Context, batchNo string, batchType int) (*rc.BatchMainRow, error) {
|
||||
main, err := r.tables.LoadBatchMain(ctx, batchNo, batchType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if main == nil {
|
||||
return nil, gerror.Newf("batch %s (type %d) does not exist", batchNo, batchType)
|
||||
}
|
||||
if main.BatchAsyncStatus != batch.AsyncStatusProcessing {
|
||||
return nil, gerror.Newf("batch %s (type %d) is not in processing state: %d", batchNo, batchType, main.BatchAsyncStatus)
|
||||
}
|
||||
return main, nil
|
||||
}
|
||||
|
||||
// LoadRunningDetails returns the running batch details ordered by row_no.
|
||||
func (r *Repository) LoadRunningDetails(ctx context.Context, batchNo string, batchType int) ([]*rc.BatchDetailRow, error) {
|
||||
return r.tables.LoadBatchDetails(ctx, batchNo, batchType, []int{consts.BatchDetailStatusRunning})
|
||||
}
|
||||
|
||||
// LoadAllDetails returns every detail regardless of status, used for the
|
||||
// confirm step (renew/change) to inspect previously committed results.
|
||||
func (r *Repository) LoadAllDetails(ctx context.Context, batchNo string, batchType int) ([]*rc.BatchDetailRow, error) {
|
||||
return r.tables.LoadBatchDetails(ctx, batchNo, batchType, nil)
|
||||
}
|
||||
|
||||
type ConvertPrepareResult struct {
|
||||
MainAffected int64
|
||||
DetailAffected int64
|
||||
}
|
||||
|
||||
// ConvertPrepareToRenewChange switches the prepare batch (type=4) to the
|
||||
// matching business type (101/102). The transaction must be the one used for
|
||||
// the detail update.
|
||||
func (r *Repository) ConvertPrepareToRenewChange(ctx context.Context, tx gdb.TX, batchNo string, targetType int) (*ConvertPrepareResult, error) {
|
||||
detailResult, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare).
|
||||
Data(do.BatchDetail{BatchType: targetType, UpdatedAt: gtime.Now()}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "convert prepare details to target type failed")
|
||||
}
|
||||
detailAffected, err := detailResult.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mainResult, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypePrepare).
|
||||
Data(do.BatchMain{BatchType: targetType, UpdatedAt: gtime.Now()}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "convert prepare batch type failed")
|
||||
}
|
||||
mainAffected, err := mainResult.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mainAffected == 0 {
|
||||
return nil, gerror.New("prepare batch main already converted or missing")
|
||||
}
|
||||
return &ConvertPrepareResult{
|
||||
MainAffected: mainAffected,
|
||||
DetailAffected: detailAffected,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MarkDetailFailed writes a single detail as failed with the given reason.
|
||||
func (r *Repository) MarkDetailFailed(ctx context.Context, tx gdb.TX, batchNo string, batchType, rowNo int, reason string, userID int64) error {
|
||||
return r.tables.UpdateBatchDetailSnapshot(ctx, tx, batchNo, batchType, rowNo, consts.BatchDetailStatusFailed, nil, reason, userID)
|
||||
}
|
||||
|
||||
// MarkDetailSuccess writes a single detail as success with the given snapshot.
|
||||
func (r *Repository) MarkDetailSuccess(ctx context.Context, tx gdb.TX, batchNo string, batchType, rowNo int, snapshot *gjson.Json, userID int64) error {
|
||||
return r.tables.UpdateBatchDetailSnapshot(ctx, tx, batchNo, batchType, rowNo, consts.BatchDetailStatusSuccess, snapshot, "", userID)
|
||||
}
|
||||
|
||||
func (r *Repository) MarkPrepareDetailSuccess(ctx context.Context, tx gdb.TX, batchNo string, rowNo int, itemID string, snapshot *gjson.Json, userID int64) error {
|
||||
data := map[string]any{
|
||||
dao.BatchDetail.Columns().DetailStatus: consts.BatchDetailStatusSuccess,
|
||||
dao.BatchDetail.Columns().Snapshot: snapshot,
|
||||
dao.BatchDetail.Columns().UpdatedBy: userID,
|
||||
dao.BatchDetail.Columns().UpdatedAt: gtime.Now(),
|
||||
}
|
||||
if itemID = strings.TrimSpace(itemID); itemID != "" {
|
||||
data[dao.BatchDetail.Columns().ItemId] = itemID
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare).
|
||||
Where(dao.BatchDetail.Columns().RowNo, rowNo).
|
||||
Data(data).
|
||||
Update()
|
||||
return gerror.Wrap(err, "mark prepare detail success failed")
|
||||
}
|
||||
|
||||
// MarkAllDetailsFailed marks every unfinished detail as failed in a single update.
|
||||
func (r *Repository) MarkAllDetailsFailed(ctx context.Context, tx gdb.TX, batchNo string, batchType int, reason string, userID int64) error {
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, batchType).
|
||||
WhereIn(dao.BatchDetail.Columns().DetailStatus, []int{consts.BatchDetailStatusPending, consts.BatchDetailStatusRunning}).
|
||||
Data(do.BatchDetail{
|
||||
DetailStatus: consts.BatchDetailStatusFailed,
|
||||
ErrorReason: reason,
|
||||
UpdatedBy: userID,
|
||||
UpdatedAt: gtime.Now(),
|
||||
}).
|
||||
Update()
|
||||
return gerror.Wrap(err, "mark all running details failed")
|
||||
}
|
||||
|
||||
// MarkAllDetailsSuccess marks every running detail as success.
|
||||
func (r *Repository) MarkAllDetailsSuccess(ctx context.Context, tx gdb.TX, batchNo string, batchType int, userID int64, extra map[string]any) error {
|
||||
data := map[string]any{
|
||||
"detail_status": consts.BatchDetailStatusSuccess,
|
||||
"updated_by": userID,
|
||||
"updated_at": gtime.Now(),
|
||||
}
|
||||
for k, v := range extra {
|
||||
data[k] = v
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, batchType).
|
||||
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
|
||||
Data(data).
|
||||
Update()
|
||||
return gerror.Wrap(err, "mark all running details success")
|
||||
}
|
||||
|
||||
// UpdateBatchMainInfo merges new entries into batch_main.batch_info and
|
||||
// updates the lifecycle counters in a single statement.
|
||||
func (r *Repository) UpdateBatchMainInfo(ctx context.Context, tx gdb.TX, batchNo string, batchType int, data *rc.BatchMainRow) error {
|
||||
return r.tables.UpdateBatchMainStatus(ctx, tx, batchNo, batchType, data)
|
||||
}
|
||||
|
||||
// FinalizeBatchMain updates only the lifecycle fields (status, counts,
|
||||
// fail_reason, end_time) while keeping batch_info untouched.
|
||||
func (r *Repository) FinalizeBatchMain(ctx context.Context, tx gdb.TX, batchNo string, batchType int, data *rc.BatchMainRow) error {
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchMain.Columns().BatchType, batchType).
|
||||
Data(do.BatchMain{
|
||||
BatchAsyncStatus: data.BatchAsyncStatus,
|
||||
RunResult: data.RunResult,
|
||||
SuccessCount: data.SuccessCount,
|
||||
FailCount: data.FailCount,
|
||||
ValidCount: data.ValidCount,
|
||||
TotalCount: data.TotalCount,
|
||||
FailReason: data.FailReason,
|
||||
EndTime: data.EndTime,
|
||||
UpdatedBy: data.UpdatedBy,
|
||||
}).
|
||||
Update()
|
||||
return gerror.Wrap(err, "finalize batch main failed")
|
||||
}
|
||||
|
||||
// ResolveResultSnapshot returns the per-row data needed to render the result
|
||||
// file. It always includes every detail (success and failure) for the batch.
|
||||
func (r *Repository) ResolveResultSnapshot(ctx context.Context, batchNo string, batchType int) ([]ResultRow, error) {
|
||||
details, err := r.tables.LoadBatchDetails(ctx, batchNo, batchType, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]ResultRow, 0, len(details))
|
||||
for _, d := range details {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
msisdn, imsi := "", ""
|
||||
targetCode, packageCode, effectiveMode, orderNum := "", "", "", ""
|
||||
if d.Snapshot != nil {
|
||||
msisdn = strings.TrimSpace(d.Snapshot.Get("msisdn").String())
|
||||
imsi = strings.TrimSpace(d.Snapshot.Get("imsi").String())
|
||||
targetCode = strings.TrimSpace(d.Snapshot.Get("targetCode").String())
|
||||
if targetCode == "" {
|
||||
targetCode = strings.TrimSpace(d.ItemId)
|
||||
}
|
||||
packageCode = strings.TrimSpace(d.Snapshot.Get("packageCode").String())
|
||||
effectiveMode = strings.TrimSpace(d.Snapshot.Get("effectiveMode").String())
|
||||
orderNum = strings.TrimSpace(d.Snapshot.Get("orderSn").String())
|
||||
if orderNum == "" {
|
||||
orderNum = strings.TrimSpace(d.Snapshot.Get("orderNo").String())
|
||||
}
|
||||
}
|
||||
rows = append(rows, ResultRow{
|
||||
RowNo: d.RowNo,
|
||||
ICCID: strings.TrimSpace(d.ItemId),
|
||||
TargetCode: targetCode,
|
||||
PackageCode: packageCode,
|
||||
EffectiveMode: effectiveMode,
|
||||
MSISDN: msisdn,
|
||||
IMSI: imsi,
|
||||
Success: d.DetailStatus == consts.BatchDetailStatusSuccess,
|
||||
Reason: strings.TrimSpace(d.ErrorReason),
|
||||
OrderNum: orderNum,
|
||||
Snap: d.Snapshot,
|
||||
DetailID: d.Id,
|
||||
DetailCode: d.ErrorCode,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// WriteResultFileSafe persists a result Excel and OSS/download records. It
|
||||
// never panics on a bad batch type; missing configuration is logged and the
|
||||
// business outcome is preserved. The caller must not abort the batch when
|
||||
// this returns an error.
|
||||
func (r *Repository) WriteResultFileSafe(ctx context.Context, batchNo string, batchType int, tenantID string, deptID, userID int64, rows []ResultRow) error {
|
||||
if r.results == nil {
|
||||
return nil
|
||||
}
|
||||
brRows := make([]batchresult.Row, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
brRows = append(brRows, batchresult.Row{
|
||||
RowNo: row.RowNo,
|
||||
ObjectCode: row.TargetCode,
|
||||
TargetCode: row.TargetCode,
|
||||
PackageCode: row.PackageCode,
|
||||
EffectiveMode: row.EffectiveMode,
|
||||
ICCID: row.ICCID,
|
||||
MSISDN: row.MSISDN,
|
||||
IMSI: row.IMSI,
|
||||
Success: row.Success,
|
||||
Reason: row.Reason,
|
||||
OrderNum: row.OrderNum,
|
||||
})
|
||||
}
|
||||
request := batchresult.Request{
|
||||
BatchNo: batchNo,
|
||||
BatchType: batchType,
|
||||
TenantID: strings.TrimSpace(tenantID),
|
||||
Rows: brRows,
|
||||
}
|
||||
artifact, err := r.results.Create(ctx, request)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "renew/change result file generation failed, batchNo:%s, batchType:%d, err:%+v", batchNo, batchType, err)
|
||||
return err
|
||||
}
|
||||
if err = r.persistArtifact(ctx, artifact, batchNo, batchType, tenantID, deptID, userID, len(rows)); err != nil {
|
||||
g.Log().Warningf(ctx, "renew/change result file bookkeeping failed, batchNo:%s, batchType:%d, err:%+v", batchNo, batchType, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) persistArtifact(ctx context.Context, artifact batchresult.Artifact, batchNo string, batchType int, tenantID string, deptID, userID int64, total int) error {
|
||||
return r.tables.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
ossID, err := r.tables.InsertSysOss(ctx, tx, &rc.SysOssRow{
|
||||
TenantId: tenantID,
|
||||
FileName: artifact.FileName,
|
||||
OriginalName: artifact.OriginalName,
|
||||
FileSuffix: artifact.FileSuffix,
|
||||
Path: artifact.Path,
|
||||
Url: artifact.URL,
|
||||
Md5: artifact.MD5,
|
||||
FileSize: artifact.Size,
|
||||
Service: "local",
|
||||
CreatedDept: deptID,
|
||||
CreatedBy: userID,
|
||||
UpdatedBy: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
successCount, failCount := 0, 0
|
||||
main, mErr := r.tables.LoadBatchMain(ctx, batchNo, batchType)
|
||||
if mErr == nil && main != nil {
|
||||
successCount = main.SuccessCount
|
||||
failCount = main.FailCount
|
||||
}
|
||||
_, err = r.tables.InsertBatchDownload(ctx, tx, &rc.ResourceBatchDownloadRow{
|
||||
BatchNo: batchNo,
|
||||
TaskName: batchresult.TaskName(batchType),
|
||||
ResourceType: consts.ResourceTypeExcel,
|
||||
ExportScene: batchType,
|
||||
TotalCount: total,
|
||||
SuccessCount: successCount,
|
||||
FailCount: failCount,
|
||||
DownloadStatus: consts.DownloadStatusGenerated,
|
||||
OssId: ossID,
|
||||
FileName: artifact.FileName,
|
||||
FileExt: artifact.FileSuffix,
|
||||
FileUrl: artifact.URL,
|
||||
FilePath: artifact.Path,
|
||||
FileSize: artifact.Size,
|
||||
FileMd5: artifact.MD5,
|
||||
DownloadCount: 0,
|
||||
DeptId: deptID,
|
||||
StartTime: gtime.Now(),
|
||||
FinishTime: gtime.Now(),
|
||||
TenantId: tenantID,
|
||||
CreatedDept: deptID,
|
||||
CreatedBy: userID,
|
||||
UpdatedBy: userID,
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// LoadSystemContext returns tenant id and a default user id (CreatedBy) for
|
||||
// the given batch. Centralized so individual executors stay slim.
|
||||
func (r *Repository) LoadSystemContext(ctx context.Context, batchNo string, batchType int) (string, int64, int64, error) {
|
||||
main, err := r.tables.LoadBatchMain(ctx, batchNo, batchType)
|
||||
if err != nil {
|
||||
return "", 0, 0, err
|
||||
}
|
||||
if main == nil {
|
||||
return "", 0, 0, gerror.Newf("batch %s (type %d) does not exist", batchNo, batchType)
|
||||
}
|
||||
return main.TenantId, main.CreatedBy, main.CreatedDept, nil
|
||||
}
|
||||
|
||||
// ResultRow is the per-row information needed by the result file.
|
||||
type ResultRow struct {
|
||||
RowNo int
|
||||
ICCID string
|
||||
TargetCode string
|
||||
PackageCode string
|
||||
EffectiveMode string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
Success bool
|
||||
Reason string
|
||||
OrderNum string
|
||||
Upstream int
|
||||
Snap *gjson.Json
|
||||
DetailID int64
|
||||
DetailCode string
|
||||
}
|
||||
|
||||
func tenantIDtoString(tenantID int64) string {
|
||||
return strconv.FormatInt(tenantID, 10)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
type subscriptionValidation struct {
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
TenantID string
|
||||
CustomerDeptID int64
|
||||
PackageCode string
|
||||
SourceBatchNo string
|
||||
}
|
||||
|
||||
// resolveCurrentSubscriptions selects the current package fact per ICCID.
|
||||
// It keeps future pending subscriptions for change-package cleanup, while
|
||||
// allowing renew to append after the farthest pending subscription.
|
||||
func resolveCurrentSubscriptions(rows []*rc.SimCardBindPkg, iccids []string) (map[string]*rc.SimCardBindPkg, map[string][]*rc.SimCardBindPkg, error) {
|
||||
grouped := make(map[string][]*rc.SimCardBindPkg, len(iccids))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(row.Iccid)
|
||||
if iccid == "" {
|
||||
continue
|
||||
}
|
||||
grouped[iccid] = append(grouped[iccid], row)
|
||||
}
|
||||
|
||||
current := make(map[string]*rc.SimCardBindPkg, len(iccids))
|
||||
future := make(map[string][]*rc.SimCardBindPkg, len(iccids))
|
||||
for _, rawICCID := range iccids {
|
||||
iccid := strings.TrimSpace(rawICCID)
|
||||
selected, pending, err := selectCurrentSubscription(grouped[iccid], iccid)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
current[iccid] = selected
|
||||
future[iccid] = pending
|
||||
}
|
||||
return current, future, nil
|
||||
}
|
||||
|
||||
func selectCurrentSubscription(rows []*rc.SimCardBindPkg, iccid string) (*rc.SimCardBindPkg, []*rc.SimCardBindPkg, error) {
|
||||
var (
|
||||
effective *rc.SimCardBindPkg
|
||||
pending []*rc.SimCardBindPkg
|
||||
expired []*rc.SimCardBindPkg
|
||||
)
|
||||
for _, row := range rows {
|
||||
if row == nil || strings.TrimSpace(row.Iccid) != strings.TrimSpace(iccid) {
|
||||
continue
|
||||
}
|
||||
if consts.IsAddPackageType(row.PackageType) {
|
||||
continue
|
||||
}
|
||||
switch row.EffectiveStatus {
|
||||
case consts.EffectiveStatusEffective:
|
||||
if effective != nil {
|
||||
return nil, nil, gerror.Newf("同一张卡存在多个生效中的套餐订购记录:%s", iccid)
|
||||
}
|
||||
effective = row
|
||||
case consts.EffectiveStatusPending:
|
||||
pending = append(pending, row)
|
||||
case consts.EffectiveStatusExpired:
|
||||
expired = append(expired, row)
|
||||
}
|
||||
}
|
||||
sortPendingSubscriptions(pending)
|
||||
if effective != nil {
|
||||
return effective, pending, nil
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
return pending[0], pending[1:], nil
|
||||
}
|
||||
if latest := latestExpiredSubscription(expired); latest != nil {
|
||||
return latest, nil, nil
|
||||
}
|
||||
return nil, nil, gerror.Newf("ICCID没有当前套餐订购记录:%s", iccid)
|
||||
}
|
||||
|
||||
func latestExpiredSubscription(rows []*rc.SimCardBindPkg) *rc.SimCardBindPkg {
|
||||
var latest *rc.SimCardBindPkg
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
if latest == nil || subscriptionAfter(row, latest) {
|
||||
latest = row
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func sortPendingSubscriptions(rows []*rc.SimCardBindPkg) {
|
||||
if len(rows) <= 1 {
|
||||
return
|
||||
}
|
||||
for i := 1; i < len(rows); i++ {
|
||||
current := rows[i]
|
||||
j := i - 1
|
||||
for j >= 0 && subscriptionAfter(rows[j], current) {
|
||||
rows[j+1] = rows[j]
|
||||
j--
|
||||
}
|
||||
rows[j+1] = current
|
||||
}
|
||||
}
|
||||
|
||||
func subscriptionAfter(left, right *rc.SimCardBindPkg) bool {
|
||||
if compareTime(expectedExpireTime(left), expectedExpireTime(right)) != 0 {
|
||||
return compareTime(expectedExpireTime(left), expectedExpireTime(right)) > 0
|
||||
}
|
||||
if compareTime(effectiveTime(left), effectiveTime(right)) != 0 {
|
||||
return compareTime(effectiveTime(left), effectiveTime(right)) > 0
|
||||
}
|
||||
return strings.TrimSpace(subscriptionNo(left)) > strings.TrimSpace(subscriptionNo(right))
|
||||
}
|
||||
|
||||
func expectedExpireTime(row *rc.SimCardBindPkg) *gtime.Time {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
return row.ExpectedExpireTime
|
||||
}
|
||||
|
||||
func effectiveTime(row *rc.SimCardBindPkg) *gtime.Time {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
return row.EffectiveTime
|
||||
}
|
||||
|
||||
func subscriptionNo(row *rc.SimCardBindPkg) string {
|
||||
if row == nil {
|
||||
return ""
|
||||
}
|
||||
return row.SubscriptionNo
|
||||
}
|
||||
|
||||
func compareTime(left, right *gtime.Time) int {
|
||||
switch {
|
||||
case left == nil && right == nil:
|
||||
return 0
|
||||
case left == nil:
|
||||
return -1
|
||||
case right == nil:
|
||||
return 1
|
||||
case left.Before(right):
|
||||
return -1
|
||||
case left.After(right):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func validateCurrentSubscription(card *rc.SimCardInfo, bind *rc.SimCardBindPkg, expected subscriptionValidation) error {
|
||||
if card == nil || bind == nil {
|
||||
return gerror.New("卡信息或当前订购记录缺失")
|
||||
}
|
||||
iccid := strings.TrimSpace(expected.ICCID)
|
||||
if iccid == "" || strings.TrimSpace(card.Iccid) != iccid || strings.TrimSpace(bind.Iccid) != iccid {
|
||||
return gerror.New("ICCID不一致")
|
||||
}
|
||||
if strings.TrimSpace(expected.TenantID) != "" &&
|
||||
(strings.TrimSpace(card.TenantId) != expected.TenantID || strings.TrimSpace(bind.TenantId) != expected.TenantID) {
|
||||
return gerror.New("租户不一致")
|
||||
}
|
||||
// t_sim_card_info.dept_id is the current card owner. A card allocation
|
||||
// changes that field, while the historical bind row keeps the department
|
||||
// that originally purchased the subscription.
|
||||
if expected.CustomerDeptID <= 0 || card.DeptId != expected.CustomerDeptID {
|
||||
return gerror.New("客户部门不一致")
|
||||
}
|
||||
packageCode := strings.TrimSpace(expected.PackageCode)
|
||||
if packageCode == "" || strings.TrimSpace(card.CurrentPackageCode) != packageCode || strings.TrimSpace(bind.PackageCode) != packageCode {
|
||||
return gerror.New("当前套餐不一致")
|
||||
}
|
||||
if card.PackageStatus == consts.PackageStatusNone {
|
||||
return gerror.New("卡没有当前套餐")
|
||||
}
|
||||
if msisdn := strings.TrimSpace(expected.MSISDN); msisdn != "" && strings.TrimSpace(card.Msisdn) != msisdn {
|
||||
return gerror.New("MSISDN不一致")
|
||||
}
|
||||
if bindMSISDN := strings.TrimSpace(bind.Msisdn); bindMSISDN != "" && strings.TrimSpace(card.Msisdn) != bindMSISDN {
|
||||
return gerror.New("订购记录MSISDN不一致")
|
||||
}
|
||||
if imsi := strings.TrimSpace(expected.IMSI); imsi != "" && strings.TrimSpace(card.Imsi) != imsi {
|
||||
return gerror.New("IMSI不一致")
|
||||
}
|
||||
if sourceBatchNo := strings.TrimSpace(expected.SourceBatchNo); sourceBatchNo != "" {
|
||||
bindBatchNo := strings.TrimSpace(bind.BatchNo)
|
||||
outboundBatchNo := strings.TrimSpace(card.OutboundBatchNo)
|
||||
if bindBatchNo != sourceBatchNo && outboundBatchNo != sourceBatchNo {
|
||||
return gerror.New("当前订购来源批次不一致")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
func TestResolveCurrentSubscriptionsRejectsMissingAndDuplicatePriorities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rows []*rc.SimCardBindPkg
|
||||
}{
|
||||
{name: "missing"},
|
||||
{
|
||||
name: "duplicate effective",
|
||||
rows: []*rc.SimCardBindPkg{
|
||||
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusEffective},
|
||||
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusEffective},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, _, err := resolveCurrentSubscriptions(tt.rows, []string{"8986001"}); err == nil {
|
||||
t.Fatal("resolveCurrentSubscriptions() error = nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectCurrentSubscriptionIgnoresCardAddPackage(t *testing.T) {
|
||||
iccid := "8986000000000000103"
|
||||
formal := &rc.SimCardBindPkg{
|
||||
Iccid: iccid,
|
||||
PackageType: consts.PackageTypeCard,
|
||||
PackageCode: "CARD-FORMAL",
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
}
|
||||
addPackage := &rc.SimCardBindPkg{
|
||||
Iccid: iccid,
|
||||
PackageType: consts.PackageTypeCardAddPackage,
|
||||
PackageCode: "CARD-ADD",
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
}
|
||||
|
||||
current, future, err := selectCurrentSubscription([]*rc.SimCardBindPkg{formal, addPackage}, iccid)
|
||||
if err != nil {
|
||||
t.Fatalf("selectCurrentSubscription() error = %v", err)
|
||||
}
|
||||
if current != formal {
|
||||
t.Fatalf("current package = %+v, want formal package", current)
|
||||
}
|
||||
if len(future) != 0 {
|
||||
t.Fatalf("future package count = %d, want 0", len(future))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectCurrentSubscriptionIgnoresPoolAddPackage(t *testing.T) {
|
||||
iccid := "8986000000000000104"
|
||||
formal := &rc.SimCardBindPkg{
|
||||
Iccid: iccid,
|
||||
PackageType: consts.PackageTypePool,
|
||||
PackageCode: "POOL-FORMAL",
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
}
|
||||
addPackage := &rc.SimCardBindPkg{
|
||||
Iccid: iccid,
|
||||
PackageType: consts.PackageTypePoolAddPackage,
|
||||
PackageCode: "POOL-ADD",
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
}
|
||||
|
||||
current, _, err := selectCurrentSubscription([]*rc.SimCardBindPkg{addPackage, formal}, iccid)
|
||||
if err != nil {
|
||||
t.Fatalf("selectCurrentSubscription() error = %v", err)
|
||||
}
|
||||
if current != formal {
|
||||
t.Fatalf("current package = %+v, want formal package", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectCurrentSubscriptionRejectsTwoEffectiveFormalPackages(t *testing.T) {
|
||||
iccid := "8986000000000000102"
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{
|
||||
Iccid: iccid,
|
||||
PackageType: consts.PackageTypeCard,
|
||||
PackageCode: "FORMAL-1",
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
},
|
||||
{
|
||||
Iccid: iccid,
|
||||
PackageType: consts.PackageTypePool,
|
||||
PackageCode: "FORMAL-2",
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := selectCurrentSubscription(rows, iccid)
|
||||
if err == nil {
|
||||
t.Fatal("selectCurrentSubscription() error = nil, want duplicate formal package error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "多个生效中的套餐订购记录") {
|
||||
t.Fatalf("error = %q, want duplicate formal package message", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentSubscriptionsAllowsChainedPendingRenewals(t *testing.T) {
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{Iccid: "8986001", SubscriptionNo: "SUB-E", EffectiveStatus: consts.EffectiveStatusEffective},
|
||||
{Iccid: "8986001", SubscriptionNo: "SUB-P2", EffectiveStatus: consts.EffectiveStatusPending, ExpectedExpireTime: gtime.NewFromStr("2028-01-01 00:00:00")},
|
||||
{Iccid: "8986001", SubscriptionNo: "SUB-P1", EffectiveStatus: consts.EffectiveStatusPending, ExpectedExpireTime: gtime.NewFromStr("2027-01-01 00:00:00")},
|
||||
}
|
||||
current, future, err := resolveCurrentSubscriptions(rows, []string{"8986001"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCurrentSubscriptions() error = %v", err)
|
||||
}
|
||||
if current["8986001"].SubscriptionNo != "SUB-E" {
|
||||
t.Fatalf("current subscription = %q, want SUB-E", current["8986001"].SubscriptionNo)
|
||||
}
|
||||
if len(future["8986001"]) != 2 {
|
||||
t.Fatalf("future subscriptions len = %d, want 2", len(future["8986001"]))
|
||||
}
|
||||
if future["8986001"][0].SubscriptionNo != "SUB-P1" || future["8986001"][1].SubscriptionNo != "SUB-P2" {
|
||||
t.Fatalf("future subscriptions order = %+v, want SUB-P1,SUB-P2", future["8986001"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentSubscriptionsFallsBackToLatestExpiredSubscription(t *testing.T) {
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{
|
||||
Iccid: "8986001",
|
||||
SubscriptionNo: "SUB-X1",
|
||||
EffectiveStatus: consts.EffectiveStatusExpired,
|
||||
ExpectedExpireTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
},
|
||||
{
|
||||
Iccid: "8986001",
|
||||
SubscriptionNo: "SUB-X2",
|
||||
EffectiveStatus: consts.EffectiveStatusExpired,
|
||||
ExpectedExpireTime: gtime.NewFromStr("2026-07-01 00:00:00"),
|
||||
},
|
||||
{
|
||||
Iccid: "8986001",
|
||||
SubscriptionNo: "SUB-T",
|
||||
EffectiveStatus: consts.EffectiveStatusTerminated,
|
||||
},
|
||||
}
|
||||
current, future, err := resolveCurrentSubscriptions(rows, []string{"8986001"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCurrentSubscriptions() error = %v", err)
|
||||
}
|
||||
if current["8986001"] == nil || current["8986001"].SubscriptionNo != "SUB-X2" {
|
||||
t.Fatalf("current subscription = %+v, want SUB-X2", current["8986001"])
|
||||
}
|
||||
if len(future["8986001"]) != 0 {
|
||||
t.Fatalf("future subscriptions = %+v, want empty", future["8986001"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentSubscriptionsPrefersPendingOverExpiredHistory(t *testing.T) {
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{
|
||||
Iccid: "8986001",
|
||||
SubscriptionNo: "SUB-X",
|
||||
EffectiveStatus: consts.EffectiveStatusExpired,
|
||||
ExpectedExpireTime: gtime.NewFromStr("2026-07-01 00:00:00"),
|
||||
},
|
||||
{
|
||||
Iccid: "8986001",
|
||||
SubscriptionNo: "SUB-P",
|
||||
EffectiveStatus: consts.EffectiveStatusPending,
|
||||
ExpectedExpireTime: gtime.NewFromStr("2027-07-01 00:00:00"),
|
||||
},
|
||||
}
|
||||
current, future, err := resolveCurrentSubscriptions(rows, []string{"8986001"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCurrentSubscriptions() error = %v", err)
|
||||
}
|
||||
if current["8986001"] == nil || current["8986001"].SubscriptionNo != "SUB-P" {
|
||||
t.Fatalf("current subscription = %+v, want SUB-P", current["8986001"])
|
||||
}
|
||||
if len(future["8986001"]) != 0 {
|
||||
t.Fatalf("future subscriptions = %+v, want empty", future["8986001"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentSubscriptionsStillRejectsOnlyTerminatedOrVoidedHistory(t *testing.T) {
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusTerminated},
|
||||
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusVoided},
|
||||
}
|
||||
if _, _, err := resolveCurrentSubscriptions(rows, []string{"8986001"}); err == nil {
|
||||
t.Fatal("terminated/voided history was accepted as current subscription")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCurrentSubscriptionChecksPackageAndBusinessSource(t *testing.T) {
|
||||
card, bind, expected := validSubscriptionValidationFixture()
|
||||
if err := validateCurrentSubscription(card, bind, expected); err != nil {
|
||||
t.Fatalf("validateCurrentSubscription() error = %v", err)
|
||||
}
|
||||
|
||||
bind.PackageCode = "OTHER"
|
||||
if err := validateCurrentSubscription(card, bind, expected); err == nil {
|
||||
t.Fatal("package mismatch was accepted")
|
||||
}
|
||||
bind.PackageCode = expected.PackageCode
|
||||
bind.BatchNo = "OUT-OTHER"
|
||||
card.OutboundBatchNo = "OUT-OTHER"
|
||||
if err := validateCurrentSubscription(card, bind, expected); err == nil {
|
||||
t.Fatal("source batch mismatch was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCurrentSubscriptionIgnoresSourceForInputSelfReference(t *testing.T) {
|
||||
card, bind, expected := validSubscriptionValidationFixture()
|
||||
bind.BatchNo = "OUT-ACTUAL"
|
||||
card.OutboundBatchNo = ""
|
||||
expected.SourceBatchNo = ""
|
||||
if err := validateCurrentSubscription(card, bind, expected); err != nil {
|
||||
t.Fatalf("input source should be ignored, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCurrentSubscriptionRejectsIdentityChanges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*rc.SimCardInfo, *rc.SimCardBindPkg, *subscriptionValidation)
|
||||
}{
|
||||
{name: "ICCID", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) { card.Iccid = "8986002" }},
|
||||
{name: "MSISDN", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) {
|
||||
card.Msisdn = "13900000000"
|
||||
}},
|
||||
{name: "IMSI", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) { card.Imsi = "460002" }},
|
||||
{name: "tenant", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) { card.TenantId = "OTHER" }},
|
||||
{name: "customer", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) { card.DeptId = 118 }},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
card, bind, expected := validSubscriptionValidationFixture()
|
||||
tt.mutate(card, bind, &expected)
|
||||
if err := validateCurrentSubscription(card, bind, expected); err == nil {
|
||||
t.Fatalf("%s change was accepted", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCurrentSubscriptionAllowsAllocatedCardWithHistoricalBindDept(t *testing.T) {
|
||||
card, bind, expected := validSubscriptionValidationFixture()
|
||||
bind.DeptId = 100
|
||||
if err := validateCurrentSubscription(card, bind, expected); err != nil {
|
||||
t.Fatalf("allocated card with historical bind department was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validSubscriptionValidationFixture() (*rc.SimCardInfo, *rc.SimCardBindPkg, subscriptionValidation) {
|
||||
expected := subscriptionValidation{
|
||||
ICCID: "8986001",
|
||||
MSISDN: "13800000000",
|
||||
IMSI: "460001",
|
||||
TenantID: "000000",
|
||||
CustomerDeptID: 117,
|
||||
PackageCode: "PKG-OLD",
|
||||
SourceBatchNo: "OUT-1",
|
||||
}
|
||||
card := &rc.SimCardInfo{
|
||||
Iccid: expected.ICCID,
|
||||
Msisdn: expected.MSISDN,
|
||||
Imsi: expected.IMSI,
|
||||
DeptId: expected.CustomerDeptID,
|
||||
CurrentPackageCode: expected.PackageCode,
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
OutboundBatchNo: expected.SourceBatchNo,
|
||||
TenantId: expected.TenantID,
|
||||
}
|
||||
bind := &rc.SimCardBindPkg{
|
||||
Iccid: expected.ICCID,
|
||||
Msisdn: expected.MSISDN,
|
||||
DeptId: expected.CustomerDeptID,
|
||||
PackageCode: expected.PackageCode,
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
BatchNo: expected.SourceBatchNo,
|
||||
TenantId: expected.TenantID,
|
||||
}
|
||||
return card, bind, expected
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
const (
|
||||
SourceTypeExcel = 3
|
||||
SourceTypeInput = 2
|
||||
|
||||
// 价格来源
|
||||
PriceSourceSystem = "system"
|
||||
PriceSourceAgentDefault = "agentDefault"
|
||||
PriceSourceTarget = "target"
|
||||
|
||||
// Agent 价格 scope_type
|
||||
PriceScopeAgentDefault = 0
|
||||
PriceScopeTargetAgent = 1
|
||||
)
|
||||
|
||||
// RenewRow 续费/变更行(按业务语义复用同一行结构)。
|
||||
type RenewRow struct {
|
||||
RowNo int
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
NewPkgCode string
|
||||
Reason string
|
||||
PackageCode string
|
||||
CustomerID int64
|
||||
// 续费使用
|
||||
OutboundBatchNo string
|
||||
// 变更使用
|
||||
OldPkgCode string
|
||||
OldPkgName string
|
||||
OldBindSubNo string
|
||||
OldBindOrdNo string
|
||||
OldEffTime *gtime.Time
|
||||
OldExpireTime *gtime.Time
|
||||
// 准备结果快照
|
||||
SourceBatchNo string
|
||||
}
|
||||
|
||||
// RenewPriceSnapshot 续费价格快照(与 Admin 对齐)。
|
||||
type RenewPriceSnapshot struct {
|
||||
CustomerAgentId int64 `json:"customerAgentId"`
|
||||
PriceOwnerDeptId int64 `json:"priceOwnerDeptId"`
|
||||
PackageCode string `json:"packageCode"`
|
||||
PackageName string `json:"packageName"`
|
||||
BillingMethod int `json:"billingMethod"`
|
||||
RenewDurationUnit string `json:"renewDurationUnit"`
|
||||
RenewDurationUnitText string `json:"renewDurationUnitText"`
|
||||
PriceSource string `json:"priceSource"`
|
||||
AgentDefaultPriceId int64 `json:"agentDefaultPriceId"`
|
||||
TargetPriceId int64 `json:"targetPriceId"`
|
||||
RenewUnitPrice float64 `json:"renewUnitPrice"`
|
||||
RenewableDurationOptions []int `json:"renewableDurationOptions"`
|
||||
RenewablePackageCodes []string `json:"renewablePackageCodes"`
|
||||
RenewablePackages []RenewPackageOption `json:"renewablePackages"`
|
||||
RenewBeginDate string `json:"renewBeginDate"`
|
||||
RenewBeginDateMin string `json:"renewBeginDateMin"`
|
||||
RenewBeginDateMax string `json:"renewBeginDateMax"`
|
||||
SameRenewBeginDate bool `json:"sameRenewBeginDate"`
|
||||
}
|
||||
|
||||
type RenewPackageOption struct {
|
||||
PackageCode string `json:"packageCode"`
|
||||
PackageName string `json:"packageName"`
|
||||
RenewUnitPrice float64 `json:"renewUnitPrice"`
|
||||
RenewableDurationOptions []int `json:"renewableDurationOptions"`
|
||||
}
|
||||
|
||||
// ChangePriceSnapshot 变更价格快照。
|
||||
type ChangePriceSnapshot struct {
|
||||
CustomerAgentId int64 `json:"customerAgentId"`
|
||||
NewPackageCode string `json:"newPackageCode"`
|
||||
NewPackageName string `json:"newPackageName"`
|
||||
PriceSource string `json:"priceSource"`
|
||||
AgentDefaultPriceId int64 `json:"agentDefaultPriceId"`
|
||||
TargetPriceId int64 `json:"targetPriceId"`
|
||||
ChangeUnitPrice float64 `json:"changeUnitPrice"`
|
||||
RenewableDurationOptions []int `json:"renewableDurationOptions"`
|
||||
}
|
||||
|
||||
// ChangePrepareSnapshot 变更预处理快照。
|
||||
type ChangePrepareSnapshot struct {
|
||||
SourceBatchNo string `json:"sourceBatchNo"`
|
||||
CustomerAgentId int64 `json:"customerAgentId"`
|
||||
CustomerName string `json:"customerName"`
|
||||
OldPackageCode string `json:"oldPackageCode"`
|
||||
OldPackageName string `json:"oldPackageName"`
|
||||
OldPackageType int `json:"oldPackageType"`
|
||||
OldPackageFlowMb float64 `json:"oldPackageFlowMb"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
// Task 续费/变更通用任务。
|
||||
type Task struct {
|
||||
Batch *rc.BatchMainRow
|
||||
Rows []*RenewRow
|
||||
FailureReason string
|
||||
ValidCount int
|
||||
SuccessCount int
|
||||
FailCount int
|
||||
|
||||
// 续费
|
||||
PriceSnapshot *RenewPriceSnapshot
|
||||
|
||||
// 变更
|
||||
PrepareSnapshot *ChangePrepareSnapshot
|
||||
PriceSnapshot2 *ChangePriceSnapshot
|
||||
}
|
||||
|
||||
// Executor 抽象。
|
||||
type Executor interface {
|
||||
Execute(ctx context.Context, batchNo string) error
|
||||
}
|
||||
|
||||
// NowFunc 提供时钟,便于测试。
|
||||
type NowFunc func() time.Time
|
||||
|
||||
// timeNow 默认时钟。
|
||||
func timeNow() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// addMonths 在给定时间上增加 N 个月,保持日时分秒。
|
||||
func addMonths(t time.Time, months int) time.Time {
|
||||
if months <= 0 {
|
||||
return t
|
||||
}
|
||||
targetMonth := time.Date(
|
||||
t.Year(),
|
||||
t.Month()+time.Month(months),
|
||||
1,
|
||||
t.Hour(),
|
||||
t.Minute(),
|
||||
t.Second(),
|
||||
t.Nanosecond(),
|
||||
t.Location(),
|
||||
)
|
||||
day := t.Day()
|
||||
lastDay := time.Date(
|
||||
targetMonth.Year(),
|
||||
targetMonth.Month()+1,
|
||||
0,
|
||||
t.Hour(),
|
||||
t.Minute(),
|
||||
t.Second(),
|
||||
t.Nanosecond(),
|
||||
t.Location(),
|
||||
).Day()
|
||||
if day > lastDay {
|
||||
day = lastDay
|
||||
}
|
||||
return time.Date(
|
||||
targetMonth.Year(),
|
||||
targetMonth.Month(),
|
||||
day,
|
||||
t.Hour(),
|
||||
t.Minute(),
|
||||
t.Second(),
|
||||
t.Nanosecond(),
|
||||
t.Location(),
|
||||
)
|
||||
}
|
||||
|
||||
func renewBeginDateFromExpire(expireAt *gtime.Time) *gtime.Time {
|
||||
if expireAt == nil {
|
||||
return nil
|
||||
}
|
||||
return dayEndAt(expireAt)
|
||||
}
|
||||
|
||||
func renewBeginTimeFromExpire(expireAt *gtime.Time) (time.Time, bool) {
|
||||
beginAt := renewBeginDateFromExpire(expireAt)
|
||||
if beginAt == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return beginAt.Time, true
|
||||
}
|
||||
|
||||
func previousMonthEndAt(t *gtime.Time) *gtime.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
tt := t.Time
|
||||
return gtime.NewFromTime(time.Date(tt.Year(), tt.Month(), 0, 23, 59, 59, 0, tt.Location()))
|
||||
}
|
||||
|
||||
func dayEndAt(t *gtime.Time) *gtime.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
tt := t.Time
|
||||
return gtime.NewFromTime(time.Date(tt.Year(), tt.Month(), tt.Day(), 23, 59, 59, 0, tt.Location()))
|
||||
}
|
||||
|
||||
func renewDurationUnitForBillingMethod(billingMethod int) string {
|
||||
return consts.BillingMethodDurationUnit(billingMethod)
|
||||
}
|
||||
|
||||
func renewDurationUnitForPackage(pkg *rc.FlowPackageInfo) string {
|
||||
return consts.RenewDurationUnitYear
|
||||
}
|
||||
|
||||
func renewableYearOptions(config *gjson.Json) ([]int, error) {
|
||||
values, err := rc.RenewableDurationOptions(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacyMonthConfig := len(values) > 0
|
||||
for _, value := range values {
|
||||
if value < 12 || value%12 != 0 {
|
||||
legacyMonthConfig = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !legacyMonthConfig {
|
||||
return values, nil
|
||||
}
|
||||
years := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
years = append(years, value/12)
|
||||
}
|
||||
sort.Ints(years)
|
||||
return years, nil
|
||||
}
|
||||
|
||||
// changeDurationOptions returns months because the change contract and the
|
||||
// subscription records store the actual service duration in months. The
|
||||
// package configuration is normally expressed in years, while legacy rows
|
||||
// may still contain month values such as [12, 24, 36].
|
||||
func changeDurationOptions(config *gjson.Json) ([]int, error) {
|
||||
values, err := rc.RenewableDurationOptions(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacyMonthConfig := len(values) > 0
|
||||
for _, value := range values {
|
||||
if value < 12 || value%12 != 0 {
|
||||
legacyMonthConfig = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if legacyMonthConfig {
|
||||
return values, nil
|
||||
}
|
||||
months := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value > 0 {
|
||||
months = append(months, value*12)
|
||||
}
|
||||
}
|
||||
sort.Ints(months)
|
||||
if len(months) == 0 {
|
||||
return nil, gerror.New("package has no valid change duration options")
|
||||
}
|
||||
return months, nil
|
||||
}
|
||||
|
||||
func renewDurationLabel(value int, unit string) string {
|
||||
if value <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d%s", value, consts.RenewDurationUnitText(unit))
|
||||
}
|
||||
|
||||
func parseRenewDate(value string, now NowFunc) (*gtime.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
t := now()
|
||||
return gtime.NewFromTime(t), nil
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
|
||||
if err != nil {
|
||||
parsed, err = time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return gtime.NewFromTime(parsed), nil
|
||||
}
|
||||
|
||||
func formatTime(t *gtime.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format("Y-m-d H:i:s")
|
||||
}
|
||||
|
||||
func snapshotIntSlice(j *gjson.Json, path string) []int {
|
||||
if j == nil {
|
||||
return nil
|
||||
}
|
||||
raw := j.Get(path)
|
||||
if raw == nil || raw.IsNil() {
|
||||
return nil
|
||||
}
|
||||
values := raw.Interfaces()
|
||||
out := make([]int, 0, len(values))
|
||||
for _, v := range values {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
out = append(out, n)
|
||||
case int32:
|
||||
out = append(out, int(n))
|
||||
case int64:
|
||||
out = append(out, int(n))
|
||||
case float32:
|
||||
out = append(out, int(n))
|
||||
case float64:
|
||||
out = append(out, int(n))
|
||||
}
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TxFn 在事务内执行回调。
|
||||
type TxFn func(ctx context.Context, fn func(ctx context.Context, tx gdb.TX) error) error
|
||||
|
||||
// DefaultTx 真实事务实现。
|
||||
func DefaultTx(database gdb.DB) TxFn {
|
||||
if database == nil {
|
||||
database = rc.DB()
|
||||
}
|
||||
return func(ctx context.Context, fn func(ctx context.Context, tx gdb.TX) error) error {
|
||||
return database.Transaction(ctx, fn)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user