Files

661 lines
23 KiB
Go

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