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