Files
iotlcm/worker-svr/internal/service/renewchange/renew_contract_runtime.go
T

633 lines
21 KiB
Go

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
}