Files

506 lines
19 KiB
Go

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))
}