feat: publish member notices and balance-aware order settlement
Deploy olons-admin production / deploy (push) Successful in 8m50s

This commit is contained in:
richjohnrambo
2026-10-22 21:56:06 +08:00
parent 5a5540785d
commit 70ef3d9d04
10 changed files with 457 additions and 163 deletions
+151 -31
View File
@@ -77,7 +77,7 @@ func (s *sSysTradeOrder) List(ctx context.Context, in *sysin.TradeOrderListInp)
}
for _, row := range list {
row.CanMatch = canAdminMatchOrder(row.AutoDispatch, row.OrderStatus, row.ApprovalRequestId, row.ScheduledMatchAt, time.Now())
manualPending := row.AutoDispatch == consts.TradeAutoDispatchDisabled && row.OrderStatus == consts.TradeOrderPending && row.FreezeStatus == consts.TradeFreezeNormal
manualPending := row.AutoDispatch == consts.TradeAutoDispatchDisabled && row.OrderStatus == consts.TradeOrderPending && row.FreezeStatus == consts.TradeFreezeNormal && row.PaymentStatus != "balance_pending"
row.CanChangeAmount = manualPending
row.CanForcePay = manualPending
row.CanFreeze = manualPending
@@ -276,19 +276,34 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
snapshotData["member_level_id"], snapshotData["level_version_id"] = levelId, levelVersion.Id
snapshotData["commission_rate"], snapshotData["manual_payment_status"] = levelVersion.CommissionRate, "settled"
snapshotData["confirmation_required"], snapshotData["deadline_at"] = false, ""
snapshotData["settled_by"], snapshotData["settled_at"] = "admin", time.Now().UTC().Format(time.RFC3339)
snapshot := gjson.New(snapshotData)
commissionLedgerId, err := s.settleMatchedOrder(ctx, order, dispatchMode, totalAmount, commissionAmount, now)
settlement, err := s.settleMatchedOrder(ctx, order, dispatchMode, totalAmount, commissionAmount, now)
if err != nil {
return err
}
balancePending := !settlement.Settled
orderStatus, message := consts.TradeOrderCompleted, "后台已匹配并完成订单,本金已返还,佣金已结算"
var completedAt *gtime.Time = now
if balancePending {
orderStatus = consts.TradeOrderPending
message = balanceShortageMessage(settlement.AvailableAmount, totalAmount)
completedAt = nil
snapshotData["balance_shortage_pending"] = true
snapshotData["balance_available_amount"] = settlement.AvailableAmount
snapshotData["balance_shortfall_amount"] = settlement.ShortfallAmount
snapshotData["manual_payment_status"] = "balance_pending"
} else {
snapshotData["balance_shortage_pending"] = false
snapshotData["balance_shortfall_amount"] = int64(0)
snapshotData["settled_by"], snapshotData["settled_at"] = "admin", time.Now().UTC().Format(time.RFC3339)
}
snapshot := gjson.New(snapshotData)
oc := dao.TradeOrder.Columns()
data := g.Map{
oc.ProductId: product.Id, oc.ProductName: product.Name,
oc.Quantity: in.Quantity, oc.UnitPriceAmount: product.PriceAmount, oc.TotalAmount: totalAmount,
oc.CommissionAmount: commissionAmount, oc.Currency: product.Currency, oc.MemberLevelId: levelId,
oc.RuleSnapshot: snapshot, oc.Message: "后台已匹配并完成订单,余额及佣金已结算", oc.OrderStatus: consts.TradeOrderCompleted,
oc.FreezeStatus: consts.TradeFreezeNormal, oc.MatchedAt: now, oc.CompletedAt: now, oc.UpdatedBy: contexts.GetUserId(ctx),
oc.RuleSnapshot: snapshot, oc.Message: message, oc.OrderStatus: orderStatus,
oc.FreezeStatus: consts.TradeFreezeNormal, oc.MatchedAt: now, oc.CompletedAt: completedAt, oc.UpdatedBy: contexts.GetUserId(ctx),
}
result, err := s.model(ctx).WherePri(order.Id).Where(oc.OrderStatus, consts.TradeOrderMatching).Data(data).Update()
if err != nil {
@@ -298,43 +313,54 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
if rows != 1 {
return gerror.New("订单状态已变化,请刷新后重试")
}
if err = s.insertMatchedSubmission(ctx, order, levelId, commissionAmount, commissionLedgerId, now, dispatchMode); err != nil {
return err
if !balancePending {
if err = s.insertMatchedSubmission(ctx, order, levelId, commissionAmount, settlement.CommissionLedgerId, now, dispatchMode); err != nil {
return err
}
}
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{
Module: "trade", Action: "trade_order.match", TargetType: "trade_order", TargetId: order.Id,
Before: order, After: data, ChangeJson: gjson.New(g.Map{"productId": product.Id, "quantity": in.Quantity, "totalAmount": totalAmount}),
Reason: "后台人工匹配商品",
Reason: message,
})
})
}
func (s *sSysTradeOrder) settleMatchedOrder(ctx context.Context, order *entity.TradeOrder, dispatchMode string, totalAmount, commissionAmount int64, now *gtime.Time) (uint64, error) {
type matchedOrderSettlement struct {
CommissionLedgerId uint64
AvailableAmount int64
ShortfallAmount int64
Settled bool
}
func (s *sSysTradeOrder) settleMatchedOrder(ctx context.Context, order *entity.TradeOrder, dispatchMode string, totalAmount, commissionAmount int64, now *gtime.Time) (*matchedOrderSettlement, error) {
wc := dao.WalletAccount.Columns()
var account *entity.WalletAccount
if err := handler.Model(dao.WalletAccount.Ctx(ctx), &handler.Option{FilterTenant: true}).
Where(wc.MemberId, order.MemberId).Where(wc.Currency, "CNY").LockUpdate().Scan(&account); err != nil {
return 0, gerror.Wrap(err, "读取会员钱包失败")
return nil, gerror.Wrap(err, "读取会员钱包失败")
}
if account == nil || account.AccountStatus != 1 {
return 0, gerror.New("会员钱包不存在或状态异常")
return nil, gerror.New("会员钱包不存在或状态异常")
}
if totalAmount <= 0 || account.AvailableAmount < math.MinInt64+totalAmount {
return 0, gerror.New("订单金额超出钱包处理范围")
return nil, gerror.New("订单金额超出钱包处理范围")
}
if commissionAmount < 0 || account.AvailableAmount > math.MaxInt64-commissionAmount {
return 0, gerror.New("订单佣金超出钱包处理范围")
return nil, gerror.New("订单佣金超出钱包处理范围")
}
if dispatchMode == "auto" && account.AvailableAmount < totalAmount {
return 0, gerror.New("自动派单商品金额不能超过会员可用余额")
if account.AvailableAmount < totalAmount {
if account.AvailableAmount < 0 && totalAmount > math.MaxInt64+account.AvailableAmount {
return nil, gerror.New("余额差额超出可计算范围")
}
return &matchedOrderSettlement{AvailableAmount: account.AvailableAmount, ShortfallAmount: totalAmount - account.AvailableAmount}, nil
}
principalAfter := account.AvailableAmount - totalAmount
settledAfter := principalAfter + commissionAmount
settledAfter := account.AvailableAmount + commissionAmount
if dispatchMode == "auto" {
settledAfter = account.AvailableAmount + commissionAmount
if account.FrozenAmount > math.MaxInt64-totalAmount {
return 0, gerror.New("订单金额超出钱包冻结范围")
return nil, gerror.New("订单金额超出钱包冻结范围")
}
}
accountData := g.Map{
@@ -342,15 +368,12 @@ func (s *sSysTradeOrder) settleMatchedOrder(ctx context.Context, order *entity.T
wc.TotalIncome: gdb.Raw(wc.TotalIncome + "+" + fmt.Sprint(commissionAmount)),
wc.Version: gdb.Raw(wc.Version + "+1"),
}
if dispatchMode == "manual" {
accountData[wc.TotalExpense] = gdb.Raw(wc.TotalExpense + "+" + fmt.Sprint(totalAmount))
}
result, err := handler.Model(dao.WalletAccount.Ctx(ctx), &handler.Option{FilterTenant: true}).WherePri(account.Id).Where(wc.Version, account.Version).Data(accountData).Update()
if err != nil {
return 0, gerror.Wrap(err, "结算订单余额失败")
return nil, gerror.Wrap(err, "结算订单余额失败")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return 0, gerror.New("会员余额已变化,请刷新后重试")
return nil, gerror.New("会员余额已变化,请刷新后重试")
}
if dispatchMode == "auto" {
@@ -368,22 +391,116 @@ func (s *sSysTradeOrder) settleMatchedOrder(ctx context.Context, order *entity.T
}
for _, entry := range entries {
if _, err = insertMatchedOrderLedger(ctx, order, account, "WL"+fmt.Sprint(order.Id)+entry.code, entry.bizType, order.OrderNo, entry.entryType, entry.direction, totalAmount, entry.before, entry.after, entry.suffix, entry.remark, now); err != nil {
return 0, err
return nil, err
}
}
} else {
if _, err = insertMatchedOrderLedger(ctx, order, account, fmt.Sprintf("WL%dMP", order.Id), "task_principal_expense", order.OrderNo, "available", consts.WalletDirectionExpense, totalAmount, account.AvailableAmount, principalAfter, "admin-manual:principal", "手动单匹配完成扣除订单金额(允许负余额)", now); err != nil {
return 0, err
return nil, err
}
if _, err = insertMatchedOrderLedger(ctx, order, account, fmt.Sprintf("WL%dMR", order.Id), "task_principal_return", order.OrderNo, "available", consts.WalletDirectionIncome, totalAmount, principalAfter, account.AvailableAmount, "admin-manual:return-available", "手动单完成返还订单本金", now); err != nil {
return nil, err
}
}
if commissionAmount == 0 {
return 0, nil
return &matchedOrderSettlement{AvailableAmount: account.AvailableAmount, Settled: true}, nil
}
commissionBefore := principalAfter
if dispatchMode == "auto" {
commissionBefore = account.AvailableAmount
ledgerId, err := insertMatchedOrderLedger(ctx, order, account, fmt.Sprintf("WL%dMC", order.Id), "commission", fmt.Sprintf("TSADMIN%d", order.Id), "available", consts.WalletDirectionIncome, commissionAmount, account.AvailableAmount, settledAfter, "admin-match:commission", "后台匹配订单完成返佣", now)
if err != nil {
return nil, err
}
return insertMatchedOrderLedger(ctx, order, account, fmt.Sprintf("WL%dMC", order.Id), "commission", fmt.Sprintf("TSADMIN%d", order.Id), "available", consts.WalletDirectionIncome, commissionAmount, commissionBefore, settledAfter, "admin-match:commission", "后台匹配订单完成返佣", now)
return &matchedOrderSettlement{CommissionLedgerId: ledgerId, AvailableAmount: settledAfter, Settled: true}, nil
}
func balanceShortageMessage(availableAmount, requiredAmount int64) string {
shortfall := requiredAmount - availableAmount
return fmt.Sprintf("余额不足:当前可用 ¥%s,订单需要 ¥%s,还差 ¥%s。充值审核通过且余额足够后,订单将自动完成。",
formatSignedTradeCents(availableAmount), formatCents(requiredAmount), formatCents(shortfall))
}
func formatSignedTradeCents(amount int64) string {
if amount >= 0 {
return formatCents(amount)
}
magnitude := uint64(-(amount + 1)) + 1
return fmt.Sprintf("-%d.%02d", magnitude/100, magnitude%100)
}
func (s *sSysTradeOrder) lockBalancePendingOrders(ctx context.Context, memberId uint64) ([]*entity.TradeOrder, error) {
oc := dao.TradeOrder.Columns()
orders := make([]*entity.TradeOrder, 0)
err := s.model(ctx).
Where(oc.MemberId, memberId).
Where(oc.OrderStatus, consts.TradeOrderPending).
Where("JSON_UNQUOTE(JSON_EXTRACT("+oc.RuleSnapshot+", '$.balance_shortage_pending')) = ?", "true").
OrderAsc(oc.Id).LockUpdate().Scan(&orders)
if err != nil {
return nil, gerror.Wrap(err, "读取余额不足待处理订单失败")
}
return orders, nil
}
func (s *sSysTradeOrder) completeBalancePendingOrders(ctx context.Context, orders []*entity.TradeOrder) error {
if len(orders) == 0 {
return nil
}
oc := dao.TradeOrder.Columns()
for _, order := range orders {
if order == nil || order.OrderStatus != consts.TradeOrderPending || order.TotalAmount <= 0 {
continue
}
snapshot := cloneOrderSnapshot(order)
dispatchMode := gjson.New(snapshot).Get("dispatch_mode").String()
if dispatchMode == "" {
dispatchMode = "manual"
}
now := gtime.Now()
settlement, err := s.settleMatchedOrder(ctx, order, dispatchMode, order.TotalAmount, order.CommissionAmount, now)
if err != nil {
return err
}
data := g.Map{oc.UpdatedBy: contexts.GetUserId(ctx)}
if !settlement.Settled {
snapshot["balance_available_amount"] = settlement.AvailableAmount
snapshot["balance_shortfall_amount"] = settlement.ShortfallAmount
data[oc.RuleSnapshot] = gjson.New(snapshot)
data[oc.Message] = balanceShortageMessage(settlement.AvailableAmount, order.TotalAmount)
if _, err = s.model(ctx).WherePri(order.Id).Where(oc.OrderStatus, consts.TradeOrderPending).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新余额不足提示失败")
}
continue
}
snapshot["balance_shortage_pending"] = false
snapshot["balance_available_amount"] = settlement.AvailableAmount
snapshot["balance_shortfall_amount"] = int64(0)
snapshot["manual_payment_status"] = "settled"
snapshot["settled_by"] = "recharge_auto"
snapshot["settled_at"] = time.Now().UTC().Format(time.RFC3339)
message := "充值审核通过后余额充足,订单已自动完成,本金已返还,佣金已结算"
data[oc.RuleSnapshot] = gjson.New(snapshot)
data[oc.Message] = message
data[oc.OrderStatus] = consts.TradeOrderCompleted
data[oc.CompletedAt] = now
result, err := s.model(ctx).WherePri(order.Id).Where(oc.OrderStatus, consts.TradeOrderPending).Data(data).Update()
if err != nil {
return gerror.Wrap(err, "自动完成余额已充足的订单失败")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return gerror.New("待处理订单状态已变化,请重试")
}
if err = s.insertMatchedSubmission(ctx, order, order.MemberLevelId, order.CommissionAmount, settlement.CommissionLedgerId, now, dispatchMode); err != nil {
return err
}
if err = service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{
Module: "trade", Action: "trade_order.complete_after_recharge", TargetType: "trade_order", TargetId: order.Id,
Before: order, After: data, ChangeJson: gjson.New(g.Map{"availableAmount": settlement.AvailableAmount, "totalAmount": order.TotalAmount}),
Reason: message,
}); err != nil {
return err
}
}
return nil
}
func (s *sSysTradeOrder) insertMatchedSubmission(ctx context.Context, order *entity.TradeOrder, levelId uint64, commissionAmount int64, ledgerId uint64, now *gtime.Time, dispatchMode string) error {
@@ -595,6 +712,9 @@ func validateManualPaymentOrder(order *entity.TradeOrder, requireUnpaid bool) er
if order.OrderStatus != consts.TradeOrderPending || order.FreezeStatus != consts.TradeFreezeNormal || order.RuleSnapshot == nil || order.RuleSnapshot.Get("dispatch_mode").String() != "manual" {
return gerror.New("当前订单状态不允许此操作")
}
if order.RuleSnapshot.Get("balance_shortage_pending").Bool() {
return gerror.New("订单因余额不足待处理,充值审核通过且余额足够后将自动完成")
}
if requireUnpaid && order.RuleSnapshot.Get("manual_payment_status").String() == "paid" {
return gerror.New("订单已经付款")
}