修复平台代管订单流程

This commit is contained in:
yml2213
2026-07-25 19:17:19 +08:00
parent e0cb15f408
commit 4bc25b87d8
35 changed files with 2216 additions and 284 deletions
+72 -42
View File
@@ -31,10 +31,14 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
if hasOpen {
return ErrCheckoutCannotSubmit
}
checkoutToUserID := order.OwnerID
if isPlatformSettlementOrder(order) && platformManagedAdminID(order) > 0 {
checkoutToUserID = platformManagedAdminID(order)
}
record := model.HandoffRecord{
OrderID: order.ID,
FromUserID: order.RenterID,
ToUserID: order.OwnerID,
ToUserID: checkoutToUserID,
Type: "renter_checkout",
Content: req.Content,
}
@@ -58,15 +62,21 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
now := time.Now()
order.HandoffStartedAt = &now
orderID := order.ID
if err := notification.Append(tx, notification.Entry{
UserID: order.OwnerID,
Type: "checkout",
Title: "租客已发起结账",
Content: "请检查账号状态和消耗明细,确认无误后完成结算;也可修改后交由租客确认。",
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
if isPlatformSettlementOrder(order) {
if err := appendManagedAdminNotification(tx, order, "checkout", "代管订单待确认结账", "租客已发起结账,请检查账号状态和消耗明细后确认。"); err != nil {
return err
}
} else {
if err := notification.Append(tx, notification.Entry{
UserID: order.OwnerID,
Type: "checkout",
Title: "租客已发起结账",
Content: "请检查账号状态和消耗明细,确认无误后完成结算;也可修改后交由租客确认。",
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
}
}
if err := tx.Save(&order).Error; err != nil {
return err
@@ -163,37 +173,18 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
return ErrCheckoutMaxRounds
}
depositDeductCent := req.DepositDeductAmountCent
if depositDeductCent <= 0 && req.OtherAmountCent > 0 {
depositDeductCent = req.OtherAmountCent
}
depositDeductCent := checkoutDepositDeductCent(req)
next, err := buildCheckout(order, checkout.InitiatedBy, checkoutStatusCountered, checkout.Content, req.EvidenceURLS, req.ConsumableAmountCent, req.CoinConsumedM, depositDeductCent, depositDeductCent, true)
if err != nil {
return err
}
now := time.Now()
checkout.Status = checkoutStatusCountered
checkout.RoundCount = round + 1
checkout.ProposedBy = userID
checkout.RentAmountCent = next.RentAmountCent
checkout.OwnerRentAmountCent = next.OwnerRentAmountCent
checkout.PlatformFeeCent = next.PlatformFeeCent
checkout.DepositAmountCent = next.DepositAmountCent
checkout.ConsumableAmountCent = next.ConsumableAmountCent
checkout.CoinConsumedM = next.CoinConsumedM
checkout.OtherAmountCent = next.OtherAmountCent
checkout.DepositDeductAmountCent = next.DepositDeductAmountCent
checkout.RenterRefundAmountCent = next.RenterRefundAmountCent
checkout.OwnerIncomeAmountCent = next.OwnerIncomeAmountCent
checkout.ShortfallCent = next.ShortfallCent
checkout.OvershootAmountCent = next.OvershootAmountCent
checkout.OwnerAdjustmentReason = req.Reason
checkout.OwnerAdjustedAt = &now
checkout.EvidenceURLS = next.EvidenceURLS
applyCounterCheckoutUpdate(checkout, next, req.Reason, userID, round+1, now)
notifyUserID := order.RenterID
notifyTitle := "号主已修改结账金额"
notifyContent := "请核对对方修正的消耗和结算金额。可同意完结、继续还价(最多 6 轮),或发起争议。"
notifyManagedAdmin := false
if isOwner {
checkout.Turn = checkoutTurnRenter
order.Status = orderStatusPendingCheckoutAccept
@@ -202,20 +193,30 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
checkout.Turn = checkoutTurnOwner
order.Status = orderStatusPendingCheckoutConfirm
order.HandoffStatus = handoffStatusPendingOwnerCheckout
notifyUserID = order.OwnerID
notifyTitle = "租客已修改结账金额"
if isPlatformSettlementOrder(order) {
notifyManagedAdmin = true
} else {
notifyUserID = order.OwnerID
}
}
order.SettlementStatus = settlementStatusPending
orderID := order.ID
if err := notification.Append(tx, notification.Entry{
UserID: notifyUserID,
Type: "checkout",
Title: notifyTitle,
Content: notifyContent,
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
if notifyManagedAdmin {
if err := appendManagedAdminNotification(tx, order, "checkout", notifyTitle, notifyContent); err != nil {
return err
}
} else {
if err := notification.Append(tx, notification.Entry{
UserID: notifyUserID,
Type: "checkout",
Title: notifyTitle,
Content: notifyContent,
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
}
}
if err := tx.Save(&order).Error; err != nil {
return err
@@ -238,6 +239,35 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
return &dto, nil
}
func checkoutDepositDeductCent(req CounterCheckoutRequest) int64 {
depositDeductCent := req.DepositDeductAmountCent
if depositDeductCent <= 0 && req.OtherAmountCent > 0 {
depositDeductCent = req.OtherAmountCent
}
return depositDeductCent
}
func applyCounterCheckoutUpdate(checkout *model.OrderCheckout, next model.OrderCheckout, reason string, proposedBy uint64, roundCount int, now time.Time) {
checkout.Status = checkoutStatusCountered
checkout.RoundCount = roundCount
checkout.ProposedBy = proposedBy
checkout.RentAmountCent = next.RentAmountCent
checkout.OwnerRentAmountCent = next.OwnerRentAmountCent
checkout.PlatformFeeCent = next.PlatformFeeCent
checkout.DepositAmountCent = next.DepositAmountCent
checkout.ConsumableAmountCent = next.ConsumableAmountCent
checkout.CoinConsumedM = next.CoinConsumedM
checkout.OtherAmountCent = next.OtherAmountCent
checkout.DepositDeductAmountCent = next.DepositDeductAmountCent
checkout.RenterRefundAmountCent = next.RenterRefundAmountCent
checkout.OwnerIncomeAmountCent = next.OwnerIncomeAmountCent
checkout.ShortfallCent = next.ShortfallCent
checkout.OvershootAmountCent = next.OvershootAmountCent
checkout.OwnerAdjustmentReason = reason
checkout.OwnerAdjustedAt = &now
checkout.EvidenceURLS = next.EvidenceURLS
}
// AcceptCheckout 租客同意当前提案并完结(轮到租客时)
func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID uint64) error {
var refund *refundAction
@@ -20,7 +20,9 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
order.HandoffStatus = handoffStatusReturned
order.SettlementStatus = settlementStatusSettled
order.SettledAt = &now
order.OwnerSettledAt = &now
if !isPlatformSettlementOrder(*order) {
order.OwnerSettledAt = &now
}
if err := completeAssets(tx, listing, account); err != nil {
return nil, err
}
@@ -48,6 +50,16 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
}
func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement checkoutSettlement) error {
if isPlatformSettlementOrder(*order) {
amountCent := settlement.OwnerRentIncomeCent + settlement.DepositCompensationCent
order.OfflineSettlementAmountCent = amountCent
if amountCent > 0 {
order.OfflineSettlementStatus = offlineSettlementStatusPending
} else {
order.OfflineSettlementStatus = offlineSettlementStatusNone
}
return nil
}
orderID := order.ID
var ownerEntries []wallet.Entry
if settlement.OwnerRentIncomeCent > 0 {
@@ -102,6 +114,22 @@ func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutS
func appendCheckoutCompletedNotifications(tx *gorm.DB, order *model.RentalOrder, renterContent string) error {
orderID := order.ID
if isPlatformSettlementOrder(*order) {
if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "settlement",
Title: "订单已完成",
Content: renterContent,
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
}
if order.OfflineSettlementAmountCent > 0 {
return appendManagedAdminNotification(tx, *order, "settlement", "代管订单待线下结算", "订单已完成,请按线下流程向卖家转出结算金额并回填状态。")
}
return nil
}
return notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
@@ -22,6 +22,7 @@ const (
handoffStatusReturnOverdue = "return_overdue"
handoffStatusPendingOwnerCheckout = "pending_owner_checkout"
handoffStatusPendingRenterCheckout = "pending_renter_checkout"
handoffStatusCheckoutDisputed = "checkout_disputed"
handoffStatusReturned = "returned"
handoffStatusOwnerTimeout = "owner_timeout"
handoffStatusRenterConfirmTimeout = "renter_confirm_timeout"
@@ -35,6 +36,17 @@ const (
settlementStatusPending = "pending"
settlementStatusSettled = "settled"
settlementStatusClosed = "closed"
settlementStatusDisputed = "disputed"
handoffModeOwner = "owner"
handoffModePlatform = "platform"
settlementModeOwnerWallet = "owner_wallet"
settlementModePlatformManaged = "platform_managed"
offlineSettlementStatusNone = "none"
offlineSettlementStatusPending = "pending"
offlineSettlementStatusSettled = "settled"
checkoutStatusSubmitted = "submitted"
checkoutStatusCountered = "countered"
+63 -41
View File
@@ -9,50 +9,63 @@ import (
)
type OrderDTO struct {
ID uint64 `json:"id"`
OrderNo string `json:"order_no"`
ListingID uint64 `json:"listing_id"`
ListingNo string `json:"listing_no"`
AccountID uint64 `json:"account_id"`
OwnerID uint64 `json:"owner_id"`
RenterID uint64 `json:"renter_id"`
OwnerPhone string `json:"owner_phone,omitempty"`
RenterPhone string `json:"renter_phone,omitempty"`
Title string `json:"title"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
RentedAt *time.Time `json:"rented_at"`
EstimatedDurationHours int `json:"estimated_duration_hours"`
EstimatedEndAt *time.Time `json:"estimated_end_at,omitempty"`
PriceRole string `json:"price_role,omitempty"`
DisplayAmountCent int64 `json:"display_amount_cent"`
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
SettlementStatus string `json:"settlement_status"`
RefundStatus string `json:"refund_status,omitempty"`
RefundAmountCent int64 `json:"refund_amount_cent"`
DepositHoldStatus string `json:"deposit_hold_status,omitempty"`
DepositHoldAmountCent int64 `json:"deposit_hold_amount_cent"`
DepositHoldReason string `json:"deposit_hold_reason,omitempty"`
DepositHeldAt *time.Time `json:"deposit_held_at,omitempty"`
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at,omitempty"`
ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"`
Checkout *CheckoutDTO `json:"checkout,omitempty"`
AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"`
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `json:"id"`
OrderNo string `json:"order_no"`
ListingID uint64 `json:"listing_id"`
ListingNo string `json:"listing_no"`
AccountID uint64 `json:"account_id"`
OwnerID uint64 `json:"owner_id"`
RenterID uint64 `json:"renter_id"`
OwnerPhone string `json:"owner_phone,omitempty"`
RenterPhone string `json:"renter_phone,omitempty"`
Title string `json:"title"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
RentedAt *time.Time `json:"rented_at"`
EstimatedDurationHours int `json:"estimated_duration_hours"`
EstimatedEndAt *time.Time `json:"estimated_end_at,omitempty"`
PriceRole string `json:"price_role,omitempty"`
DisplayAmountCent int64 `json:"display_amount_cent"`
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
HandoffMode string `json:"handoff_mode"`
SettlementMode string `json:"settlement_mode"`
ManagedAdminID *uint64 `json:"managed_admin_id,omitempty"`
SettlementStatus string `json:"settlement_status"`
OfflineSettlementStatus string `json:"offline_settlement_status,omitempty"`
OfflineSettlementAmountCent int64 `json:"offline_settlement_amount_cent"`
OfflineSettlementRemark string `json:"offline_settlement_remark,omitempty"`
OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"`
OfflineSettledAt *time.Time `json:"offline_settled_at,omitempty"`
RefundStatus string `json:"refund_status,omitempty"`
RefundAmountCent int64 `json:"refund_amount_cent"`
DepositHoldStatus string `json:"deposit_hold_status,omitempty"`
DepositHoldAmountCent int64 `json:"deposit_hold_amount_cent"`
DepositHoldReason string `json:"deposit_hold_reason,omitempty"`
DepositHeldAt *time.Time `json:"deposit_held_at,omitempty"`
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at,omitempty"`
ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"`
Checkout *CheckoutDTO `json:"checkout,omitempty"`
AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"`
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AdminActionsDTO struct {
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"`
PlatformCheckoutConfirm *AdminActionDTO `json:"platform_checkout_confirm,omitempty"`
PlatformCheckoutCounter *AdminActionDTO `json:"platform_checkout_counter,omitempty"`
PlatformCheckoutDispute *AdminActionDTO `json:"platform_checkout_dispute,omitempty"`
PlatformOfflineSettlement *AdminActionDTO `json:"platform_offline_settlement,omitempty"`
}
type AdminActionDTO struct {
@@ -100,6 +113,15 @@ type AdminActionRequest struct {
Reason string `json:"reason" binding:"required"`
}
type PlatformHandoffRequest struct {
Content string `json:"content" binding:"required"`
Reason string `json:"reason" binding:"required"`
}
type OfflineSettlementRequest struct {
Remark string `json:"remark"`
}
type AdminOrderQuery struct {
Page int
PageSize int
@@ -2,6 +2,8 @@ package order
import (
"context"
"errors"
"io"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/pkg/response"
@@ -74,6 +76,82 @@ func (h *Handler) AdminResetHandoff(c *gin.Context) {
h.adminAction(c, h.service.AdminResetHandoff, gin.H{"reset": true})
}
func (h *Handler) AdminPlatformHandoff(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req PlatformHandoffRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "交接说明和操作原因不能为空")
return
}
record, err := h.service.AdminPlatformHandoff(c.Request.Context(), adminID, id, req, auditMeta(c))
if err != nil {
writeOrderError(c, err)
return
}
response.Created(c, record)
}
func (h *Handler) AdminPlatformCheckoutConfirm(c *gin.Context) {
h.adminAction(c, h.service.AdminPlatformCheckoutConfirm, gin.H{"confirmed": true})
}
func (h *Handler) AdminPlatformCheckoutCounter(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req CounterCheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "结账修正原因不能为空")
return
}
checkout, err := h.service.AdminPlatformCheckoutCounter(c.Request.Context(), adminID, id, req, auditMeta(c))
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, checkout)
}
func (h *Handler) AdminPlatformCheckoutDispute(c *gin.Context) {
h.adminAction(c, h.service.AdminPlatformCheckoutDispute, gin.H{"disputed": true})
}
func (h *Handler) AdminMarkOfflineSettlement(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req OfflineSettlementRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.BadRequest(c, "线下结算备注格式不正确")
return
}
if err := h.service.AdminMarkOfflineSettlement(c.Request.Context(), adminID, id, req, auditMeta(c)); err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"settled": true})
}
func (h *Handler) AdminHoldDeposit(c *gin.Context) {
h.adminAction(c, h.service.AdminHoldDeposit, gin.H{"held": true})
}
@@ -49,6 +49,8 @@ func writeOrderError(c *gin.Context, err error) {
response.Error(c, http.StatusConflict, "checkout_deposit_shortfall", "押金不足以覆盖打超/赔付差额,无法自动完结,请发起争议由人工处理")
case errors.Is(err, ErrInvalidCheckoutAmount):
response.BadRequest(c, "结账金额不符合规则")
case errors.Is(err, ErrDisputeExists):
response.Error(c, http.StatusConflict, "dispute_exists", "当前订单已有处理中争议")
case errors.Is(err, ErrPermissionDenied):
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单")
case errors.Is(err, ErrDepositCannotHold):
@@ -57,6 +59,8 @@ func writeOrderError(c *gin.Context, err error) {
response.Error(c, http.StatusConflict, "deposit_not_held", "该订单押金未处于暂扣状态")
case errors.Is(err, ErrDepositHoldAmountEmpty):
response.Error(c, http.StatusConflict, "deposit_hold_amount_empty", "暂扣押金尚未形成可归还金额")
case errors.Is(err, ErrOfflineSettlementCannotMark):
response.Error(c, http.StatusConflict, "offline_settlement_cannot_mark", "当前订单不可确认线下结算")
case IsNotFound(err):
response.Error(c, http.StatusNotFound, "not_found", "订单不存在")
default:
+93 -37
View File
@@ -63,7 +63,11 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
AccountSnapshot: snapshot,
Status: orderStatusPendingPayment,
HandoffStatus: handoffStatusNone,
HandoffMode: listingHandoffMode(listing),
SettlementMode: listingSettlementMode(listing),
ManagedAdminID: listing.ManagedAdminID,
SettlementStatus: settlementStatusUnsettled,
OfflineSettlementStatus: offlineSettlementStatusNone,
}
if err := tx.Create(&order).Error; err != nil {
return err
@@ -94,6 +98,20 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
return r.FindForUser(ctx, renterID, createdID)
}
func listingHandoffMode(listing model.RentalListing) string {
if listing.HandoffMode != "" {
return listing.HandoffMode
}
return handoffModeOwner
}
func listingSettlementMode(listing model.RentalListing) string {
if listing.SettlementMode != "" {
return listing.SettlementMode
}
return settlementModeOwnerWallet
}
func (r *Repository) depositAmountsForOrder(tx *gorm.DB, renterID uint64, originalDepositCent int64) (int64, int64, error) {
if originalDepositCent <= 0 {
return 0, 0, nil
@@ -196,25 +214,41 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint
conversationID = listingConv.ID
}
if err := notification.Append(tx,
notification.Entry{
UserID: order.OwnerID,
Type: "order",
Title: "收到新的租号订单",
Content: "租客已完成支付,请尽快提交交接说明。",
BizType: "order",
BizID: &orderID,
},
notification.Entry{
if isPlatformHandoffOrder(*order) {
if err := appendManagedAdminNotification(tx, *order, "order", "代管订单待交接", "租客已完成支付,请尽快在订单详情中提交交接说明。"); err != nil {
return 0, err
}
if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "order",
Title: "订单支付成功",
Content: "支付已完成,等待号主提交交接说明。",
Content: "支付已完成,等待客服提交交接说明。",
BizType: "order",
BizID: &orderID,
},
); err != nil {
return 0, err
}); err != nil {
return 0, err
}
} else {
if err := notification.Append(tx,
notification.Entry{
UserID: order.OwnerID,
Type: "order",
Title: "收到新的租号订单",
Content: "租客已完成支付,请尽快提交交接说明。",
BizType: "order",
BizID: &orderID,
},
notification.Entry{
UserID: order.RenterID,
Type: "order",
Title: "订单支付成功",
Content: "支付已完成,等待号主提交交接说明。",
BizType: "order",
BizID: &orderID,
},
); err != nil {
return 0, err
}
}
if err := tx.Save(order).Error; err != nil {
return 0, err
@@ -257,25 +291,41 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64)
} else {
order.HandoffStatus = handoffStatusCancelled
}
if err := notification.Append(tx,
notification.Entry{
UserID: order.OwnerID,
Type: "order",
Title: "订单已取消",
Content: "租客已取消订单,退款待客服审核。",
BizType: "order",
BizID: &orderID,
},
notification.Entry{
if isPlatformHandoffOrder(order) {
if err := appendManagedAdminNotification(tx, order, "order", "代管订单已取消", "租客已取消订单,退款待客服审核。"); err != nil {
return err
}
if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "order",
Title: "订单取消成功",
Content: "订单已取消,退款将由客服审核后原路退回。",
BizType: "order",
BizID: &orderID,
},
); err != nil {
return err
}); err != nil {
return err
}
} else {
if err := notification.Append(tx,
notification.Entry{
UserID: order.OwnerID,
Type: "order",
Title: "订单已取消",
Content: "租客已取消订单,退款待客服审核。",
BizType: "order",
BizID: &orderID,
},
notification.Entry{
UserID: order.RenterID,
Type: "order",
Title: "订单取消成功",
Content: "订单已取消,退款将由客服审核后原路退回。",
BizType: "order",
BizID: &orderID,
},
); err != nil {
return err
}
}
if err := releaseAssetsForRental(tx, listing, account); err != nil {
return err
@@ -329,7 +379,7 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID
}
now := time.Now()
if err := tx.Model(&model.HandoffRecord{}).
Where("order_id = ? AND type = ?", order.ID, "owner_handoff").
Where("order_id = ? AND type IN ?", order.ID, []string{"owner_handoff", "platform_handoff"}).
Update("confirmed_by_renter_at", now).Error; err != nil {
return err
}
@@ -338,15 +388,21 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID
order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot)
order.RentedAt = &now
orderID := order.ID
if err := notification.Append(tx, notification.Entry{
UserID: order.OwnerID,
Type: "handoff",
Title: "租客已确认收号",
Content: "订单已进入使用中。",
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
if isPlatformHandoffOrder(order) {
if err := appendManagedAdminNotification(tx, order, "handoff", "租客已确认收号", "代管订单已进入使用中。"); err != nil {
return err
}
} else {
if err := notification.Append(tx, notification.Entry{
UserID: order.OwnerID,
Type: "handoff",
Title: "租客已确认收号",
Content: "订单已进入使用中。",
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
}
}
return tx.Save(&order).Error
})
@@ -0,0 +1,435 @@
package order
import (
"context"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/adminnotification"
"hfb_sys/backend/internal/modules/notification"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func isPlatformHandoffOrder(order model.RentalOrder) bool {
return effectiveHandoffMode(order) == handoffModePlatform
}
func isPlatformSettlementOrder(order model.RentalOrder) bool {
return effectiveSettlementMode(order) == settlementModePlatformManaged
}
func platformManagedAdminID(order model.RentalOrder) uint64 {
if order.ManagedAdminID == nil {
return 0
}
return *order.ManagedAdminID
}
func appendManagedAdminNotification(tx *gorm.DB, order model.RentalOrder, typ string, title string, content string) error {
adminID := platformManagedAdminID(order)
if adminID == 0 || title == "" {
return nil
}
return adminnotification.Append(tx, adminnotification.Entry{
AdminUserID: adminID,
Type: typ,
Title: title,
Content: content,
})
}
func canAdminPlatformHandoff(order model.RentalOrder) bool {
if !isPlatformHandoffOrder(order) {
return false
}
if order.Status != orderStatusPendingHandoff {
return false
}
return order.HandoffStatus == handoffStatusPendingOwner ||
order.HandoffStatus == handoffStatusOwnerTimeout
}
func canAdminPlatformCheckoutConfirm(order model.RentalOrder) bool {
if !isPlatformSettlementOrder(order) {
return false
}
if order.Status == orderStatusPendingCheckoutConfirm &&
order.HandoffStatus == handoffStatusPendingOwnerCheckout {
return true
}
return order.Status == orderStatusAbnormal &&
order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout
}
func canAdminMarkOfflineSettlement(order model.RentalOrder) bool {
return isPlatformSettlementOrder(order) &&
order.Status == orderStatusCompleted &&
order.SettlementStatus == settlementStatusSettled &&
effectiveOfflineSettlementStatus(order) == offlineSettlementStatusPending &&
order.OfflineSettlementAmountCent > 0
}
func (r *Repository) AdminPlatformHandoff(ctx context.Context, adminID uint64, orderID uint64, req PlatformHandoffRequest, meta AuditMeta) (*HandoffRecordDTO, error) {
var recordID uint64
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
}
if !canAdminPlatformHandoff(order) {
return ErrOrderCannotHandoff
}
beforeHandoffStatus := order.HandoffStatus
if order.ManagedAdminID == nil {
order.ManagedAdminID = &adminID
}
record := model.HandoffRecord{
OrderID: order.ID,
FromUserID: adminID,
ToUserID: order.RenterID,
Type: "platform_handoff",
Content: req.Content,
}
if err := tx.Create(&record).Error; err != nil {
return err
}
now := time.Now()
order.HandoffStatus = handoffStatusPendingRenterConfirm
order.HandoffStartedAt = &now
orderID := order.ID
content := "客服已提交交接说明,请查看交接记录,确认账号可正常登录后点击确认收号。"
if beforeHandoffStatus == handoffStatusOwnerTimeout {
content = "客服已补交交接说明,请查看交接记录,确认账号可正常登录后点击确认收号。"
}
if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "handoff",
Title: "客服已提交交接说明",
Content: content,
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.platform_handoff", "order", order.ID, meta, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
"reason": req.Reason,
"before_handoff_status": beforeHandoffStatus,
"after_handoff_status": order.HandoffStatus,
"managed_admin_id": platformManagedAdminID(order),
"handoff_content_length": len(req.Content),
}); err != nil {
return err
}
if err := tx.Save(&order).Error; err != nil {
return err
}
recordID = record.ID
return nil
})
if err != nil {
return nil, err
}
return r.findHandoffRecord(ctx, recordID)
}
func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
var refund *refundAction
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
}
if !canAdminPlatformCheckoutConfirm(order) {
return ErrCheckoutCannotConfirm
}
checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil {
return err
}
if normalizeCheckoutTurn(checkout) != checkoutTurnOwner {
return ErrCheckoutCannotConfirm
}
if err := ensureCheckoutCompletable(order, checkout); err != nil {
return err
}
beforeOrderStatus := order.Status
beforeHandoffStatus := order.HandoffStatus
beforeSettlementStatus := order.SettlementStatus
if order.ManagedAdminID == nil {
order.ManagedAdminID = &adminID
}
now := time.Now()
if err := tx.Model(&model.HandoffRecord{}).
Where("order_id = ? AND type = ?", order.ID, "renter_checkout").
Update("confirmed_by_owner_at", now).Error; err != nil {
return err
}
checkout.Status = checkoutStatusAccepted
checkout.OwnerAdjustedAt = &now
action, err := r.finalizeCheckout(tx, &order, checkout, "客服已确认结账,订单完成。")
refund = action
if err != nil {
return err
}
return appendAuditLog(tx, adminID, "order.platform_checkout_confirm", "order", order.ID, meta, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
"reason": req.Reason,
"before_order_status": beforeOrderStatus,
"after_order_status": order.Status,
"before_handoff_status": beforeHandoffStatus,
"after_handoff_status": order.HandoffStatus,
"before_settlement_status": beforeSettlementStatus,
"after_settlement_status": order.SettlementStatus,
"offline_settlement_status": order.OfflineSettlementStatus,
"offline_settlement_amount_cent": order.OfflineSettlementAmountCent,
})
})
if err != nil {
return err
}
r.startRefundBestEffort(ctx, refund)
return nil
}
func (r *Repository) AdminPlatformCheckoutCounter(ctx context.Context, adminID uint64, orderID uint64, req CounterCheckoutRequest, meta AuditMeta) (*CheckoutDTO, error) {
var checkoutID uint64
var orderSnapshot model.RentalOrder
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
}
if !canAdminPlatformCheckoutConfirm(order) {
return ErrCheckoutCannotCounter
}
if order.ManagedAdminID == nil {
order.ManagedAdminID = &adminID
}
checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil {
return err
}
if normalizeCheckoutTurn(checkout) != checkoutTurnOwner {
return ErrCheckoutCannotCounter
}
round := checkout.RoundCount
if round <= 0 {
round = 1
}
if round >= checkoutMaxRounds {
return ErrCheckoutMaxRounds
}
depositDeductCent := checkoutDepositDeductCent(req)
next, err := buildCheckout(order, checkout.InitiatedBy, checkoutStatusCountered, checkout.Content, req.EvidenceURLS, req.ConsumableAmountCent, req.CoinConsumedM, depositDeductCent, depositDeductCent, true)
if err != nil {
return err
}
now := time.Now()
beforeOrderStatus := order.Status
beforeHandoffStatus := order.HandoffStatus
applyCounterCheckoutUpdate(checkout, next, req.Reason, adminID, round+1, now)
checkout.Turn = checkoutTurnRenter
order.Status = orderStatusPendingCheckoutAccept
order.HandoffStatus = handoffStatusPendingRenterCheckout
order.SettlementStatus = settlementStatusPending
orderID := order.ID
if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "checkout",
Title: "客服已修改结账金额",
Content: "请核对客服修正的消耗和结算金额。可同意完结、继续协商,或发起争议。",
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
}
record := model.HandoffRecord{
OrderID: order.ID,
FromUserID: adminID,
ToUserID: order.RenterID,
Type: "platform_checkout_counter",
Content: "客服修改结账方案:" + req.Reason,
}
if err := tx.Create(&record).Error; err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.platform_checkout_counter", "order", order.ID, meta, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
"reason": req.Reason,
"before_order_status": beforeOrderStatus,
"after_order_status": order.Status,
"before_handoff_status": beforeHandoffStatus,
"after_handoff_status": order.HandoffStatus,
"round_count": checkout.RoundCount,
"consumable_amount_cent": checkout.ConsumableAmountCent,
"coin_consumed_m": checkout.CoinConsumedM,
"deposit_deduct_amount_cent": checkout.DepositDeductAmountCent,
"owner_income_amount_cent": checkout.OwnerIncomeAmountCent,
"renter_refund_amount_cent": checkout.RenterRefundAmountCent,
"offline_settlement_amount_cent": order.OfflineSettlementAmountCent,
}); err != nil {
return err
}
if err := tx.Save(&order).Error; err != nil {
return err
}
if err := tx.Save(checkout).Error; err != nil {
return err
}
checkoutID = checkout.ID
orderSnapshot = order
return nil
})
if err != nil {
return nil, err
}
checkout, err := r.findCheckout(ctx, checkoutID)
if err != nil {
return nil, err
}
dto := toCheckoutAdminDTO(*checkout)
refreshCheckoutSettlementDTO(&dto, orderSnapshot, *checkout)
return &dto, nil
}
func (r *Repository) AdminPlatformCheckoutDispute(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
}
if !canAdminPlatformCheckoutConfirm(order) {
return ErrCheckoutCannotCounter
}
if order.ManagedAdminID == nil {
order.ManagedAdminID = &adminID
}
var count int64
if err := tx.Model(&model.Dispute{}).
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return ErrDisputeExists
}
checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil {
return err
}
if normalizeCheckoutTurn(checkout) != checkoutTurnOwner {
return ErrCheckoutCannotCounter
}
evidence, err := marshalStringList([]string{})
if err != nil {
return err
}
beforeOrderStatus := order.Status
beforeHandoffStatus := order.HandoffStatus
beforeSettlementStatus := order.SettlementStatus
row := model.Dispute{
OrderID: order.ID,
InitiatorID: adminID,
TargetUserID: order.RenterID,
Type: "checkout_dispute",
Status: "open",
Description: req.Reason,
EvidenceURLS: evidence,
PreviousOrderStatus: beforeOrderStatus,
PreviousHandoffStatus: beforeHandoffStatus,
PreviousSettlementStatus: beforeSettlementStatus,
CheckoutID: &checkout.ID,
PreviousCheckoutStatus: checkout.Status,
}
if err := tx.Create(&row).Error; err != nil {
return err
}
now := time.Now()
order.Status = orderStatusCheckoutDisputing
order.HandoffStatus = handoffStatusCheckoutDisputed
order.SettlementStatus = settlementStatusDisputed
checkout.Status = checkoutStatusDisputed
checkout.UpdatedAt = now
record := model.HandoffRecord{
OrderID: order.ID,
FromUserID: adminID,
ToUserID: order.RenterID,
Type: "platform_checkout_dispute_opened",
Content: "客服发起结账争议:" + req.Reason,
}
if err := tx.Create(&record).Error; err != nil {
return err
}
disputeID := row.ID
orderID := order.ID
if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "dispute",
Title: "订单进入结账争议",
Content: "客服已发起结账争议,请等待仲裁处理或补充结账证据。",
BizType: "dispute",
BizID: &disputeID,
}); err != nil {
return err
}
if err := appendManagedAdminNotification(tx, order, "dispute", "代管订单进入结账争议", "客服已发起结账争议,请在争议列表继续仲裁处理。"); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.platform_checkout_dispute", "order", order.ID, meta, map[string]any{
"order_id": orderID,
"order_no": order.OrderNo,
"dispute_id": row.ID,
"reason": req.Reason,
"before_order_status": beforeOrderStatus,
"after_order_status": order.Status,
"before_handoff_status": beforeHandoffStatus,
"after_handoff_status": order.HandoffStatus,
"before_settlement_status": beforeSettlementStatus,
"after_settlement_status": order.SettlementStatus,
"checkout_id": checkout.ID,
}); err != nil {
return err
}
if err := tx.Save(&order).Error; err != nil {
return err
}
return tx.Save(checkout).Error
})
}
func (r *Repository) AdminMarkOfflineSettlement(ctx context.Context, adminID uint64, orderID uint64, req OfflineSettlementRequest, meta AuditMeta) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
}
if !canAdminMarkOfflineSettlement(order) {
return ErrOfflineSettlementCannotMark
}
beforeStatus := order.OfflineSettlementStatus
now := time.Now()
order.OfflineSettlementStatus = offlineSettlementStatusSettled
order.OfflineSettlementRemark = req.Remark
order.OfflineSettledBy = &adminID
order.OfflineSettledAt = &now
order.OwnerSettledAt = &now
if err := appendAuditLog(tx, adminID, "order.offline_settlement", "order", order.ID, meta, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
"before_offline_settlement_status": beforeStatus,
"after_offline_settlement_status": order.OfflineSettlementStatus,
"offline_settlement_amount_cent": order.OfflineSettlementAmountCent,
"remark": req.Remark,
}); err != nil {
return err
}
return tx.Save(&order).Error
})
}
+108 -43
View File
@@ -9,16 +9,46 @@ import (
)
func adminActionsForOrder(order model.RentalOrder) *AdminActionsDTO {
actions := &AdminActionsDTO{}
target, ok := resetTargetForOrder(order)
if !ok {
return nil
}
return &AdminActionsDTO{
ResetHandoff: &AdminActionDTO{
if ok {
actions.ResetHandoff = &AdminActionDTO{
Enabled: true,
Label: target.Label,
},
}
}
if canAdminPlatformHandoff(order) {
actions.PlatformHandoff = &AdminActionDTO{
Enabled: true,
Label: "客服代交接",
}
}
if canAdminPlatformCheckoutConfirm(order) {
actions.PlatformCheckoutConfirm = &AdminActionDTO{
Enabled: true,
Label: "客服确认结账",
}
actions.PlatformCheckoutCounter = &AdminActionDTO{
Enabled: true,
Label: "客服修改结账方案",
}
actions.PlatformCheckoutDispute = &AdminActionDTO{
Enabled: true,
Label: "发起结账争议",
}
}
if canAdminMarkOfflineSettlement(order) {
actions.PlatformOfflineSettlement = &AdminActionDTO{
Enabled: true,
Label: "确认线下结算",
}
}
if actions.ResetHandoff == nil && actions.PlatformHandoff == nil &&
actions.PlatformCheckoutConfirm == nil && actions.PlatformCheckoutCounter == nil &&
actions.PlatformCheckoutDispute == nil && actions.PlatformOfflineSettlement == nil {
return nil
}
return actions
}
func (row orderRow) toAdminDTO() OrderDTO {
@@ -29,43 +59,51 @@ func (row orderRow) toAdminDTO() OrderDTO {
ownerRentAmountCent := row.OwnerRentAmountCent
platformFeeCent := row.PlatformFeeCent
return OrderDTO{
ID: row.ID,
OrderNo: row.OrderNo,
ListingID: row.ListingID,
ListingNo: row.ListingNo,
AccountID: row.AccountID,
OwnerID: row.OwnerID,
RenterID: row.RenterID,
OwnerPhone: row.OwnerPhone,
RenterPhone: row.RenterPhone,
Title: row.Title,
ServerRegion: row.ServerRegion,
LoginPlatform: row.LoginPlatform,
RentedAt: rentedAt,
EstimatedDurationHours: durationHours,
EstimatedEndAt: estimatedEndAt,
PriceRole: "admin",
DisplayAmountCent: row.RentAmountCent,
RentAmountCent: &rentAmountCent,
OwnerRentAmountCent: &ownerRentAmountCent,
DepositAmountCent: row.DepositAmountCent,
DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder),
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
PlatformFeeCent: &platformFeeCent,
AccountSnapshot: row.AccountSnapshot,
Status: row.Status,
HandoffStatus: row.HandoffStatus,
SettlementStatus: row.SettlementStatus,
RefundStatus: row.RefundStatus,
RefundAmountCent: row.RefundAmountCent,
DepositHoldStatus: row.DepositHoldStatus,
DepositHoldAmountCent: row.DepositHoldAmountCent,
DepositHoldReason: row.DepositHoldReason,
DepositHeldAt: row.DepositHeldAt,
DepositHoldReleasedAt: row.DepositHoldReleasedAt,
AdminActions: adminActionsForOrder(row.RentalOrder),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
ID: row.ID,
OrderNo: row.OrderNo,
ListingID: row.ListingID,
ListingNo: row.ListingNo,
AccountID: row.AccountID,
OwnerID: row.OwnerID,
RenterID: row.RenterID,
OwnerPhone: row.OwnerPhone,
RenterPhone: row.RenterPhone,
Title: row.Title,
ServerRegion: row.ServerRegion,
LoginPlatform: row.LoginPlatform,
RentedAt: rentedAt,
EstimatedDurationHours: durationHours,
EstimatedEndAt: estimatedEndAt,
PriceRole: "admin",
DisplayAmountCent: row.RentAmountCent,
RentAmountCent: &rentAmountCent,
OwnerRentAmountCent: &ownerRentAmountCent,
DepositAmountCent: row.DepositAmountCent,
DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder),
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
PlatformFeeCent: &platformFeeCent,
AccountSnapshot: row.AccountSnapshot,
Status: row.Status,
HandoffStatus: row.HandoffStatus,
HandoffMode: effectiveHandoffMode(row.RentalOrder),
SettlementMode: effectiveSettlementMode(row.RentalOrder),
ManagedAdminID: row.ManagedAdminID,
SettlementStatus: row.SettlementStatus,
OfflineSettlementStatus: effectiveOfflineSettlementStatus(row.RentalOrder),
OfflineSettlementAmountCent: row.OfflineSettlementAmountCent,
OfflineSettlementRemark: row.OfflineSettlementRemark,
OfflineSettledBy: row.OfflineSettledBy,
OfflineSettledAt: row.OfflineSettledAt,
RefundStatus: row.RefundStatus,
RefundAmountCent: row.RefundAmountCent,
DepositHoldStatus: row.DepositHoldStatus,
DepositHoldAmountCent: row.DepositHoldAmountCent,
DepositHoldReason: row.DepositHoldReason,
DepositHeldAt: row.DepositHeldAt,
DepositHoldReleasedAt: row.DepositHoldReleasedAt,
AdminActions: adminActionsForOrder(row.RentalOrder),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
@@ -78,10 +116,37 @@ func (row orderRow) toDTOForUser(userID uint64) OrderDTO {
dto.DepositHoldReason = ""
dto.DepositHeldAt = nil
dto.DepositHoldReleasedAt = nil
dto.ManagedAdminID = nil
dto.OfflineSettlementStatus = ""
dto.OfflineSettlementAmountCent = 0
dto.OfflineSettlementRemark = ""
dto.OfflineSettledBy = nil
dto.OfflineSettledAt = nil
applyOrderPriceView(&dto, row.RentalOrder, userID)
return dto
}
func effectiveHandoffMode(order model.RentalOrder) string {
if order.HandoffMode != "" {
return order.HandoffMode
}
return handoffModeOwner
}
func effectiveSettlementMode(order model.RentalOrder) string {
if order.SettlementMode != "" {
return order.SettlementMode
}
return settlementModeOwnerWallet
}
func effectiveOfflineSettlementStatus(order model.RentalOrder) string {
if order.OfflineSettlementStatus != "" {
return order.OfflineSettlementStatus
}
return offlineSettlementStatusNone
}
func effectiveDepositOriginalAmountCent(order model.RentalOrder) int64 {
if order.DepositOriginalAmountCent > 0 {
return order.DepositOriginalAmountCent
+25 -10
View File
@@ -155,16 +155,16 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c
return model.OrderCheckout{}, err
}
return model.OrderCheckout{
OrderID: order.ID,
InitiatedBy: initiatedBy,
Status: status,
RentAmountCent: settlement.ActualRentAmountCent,
OwnerRentAmountCent: settlement.OwnerRentIncomeCent,
PlatformFeeCent: settlement.PlatformFeeCent,
DepositAmountCent: order.DepositAmountCent,
ConsumableAmountCent: consumableAmountCent,
CoinConsumedM: roundQuantity(coinConsumedM),
OtherAmountCent: otherAmountCent,
OrderID: order.ID,
InitiatedBy: initiatedBy,
Status: status,
RentAmountCent: settlement.ActualRentAmountCent,
OwnerRentAmountCent: settlement.OwnerRentIncomeCent,
PlatformFeeCent: settlement.PlatformFeeCent,
DepositAmountCent: order.DepositAmountCent,
ConsumableAmountCent: consumableAmountCent,
CoinConsumedM: roundQuantity(coinConsumedM),
OtherAmountCent: otherAmountCent,
// 存用户申报的押金赔付(损坏等),打超由结算自动计入 shortfall/overshoot
DepositDeductAmountCent: deductAmountCent,
RenterRefundAmountCent: settlement.RenterRefundCent,
@@ -219,6 +219,17 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
} else {
usedOwnerCoinPriceCent = int64(math.Round(float64(sellerCoinBasePriceCent) * coinUseRatio))
}
coinOvershoot := totalCoinM > 0 && consumedM > totalCoinM
if !coinOvershoot {
// 未打超时,比例反推只能用于折算未用完部分,不能因四舍五入超过订单快照。
if totalCoinM > 0 && consumedM >= totalCoinM {
usedBuyerCoinPriceCent = buyerCoinBasePriceCent
usedOwnerCoinPriceCent = sellerCoinBasePriceCent
} else {
usedBuyerCoinPriceCent = minCent(usedBuyerCoinPriceCent, buyerCoinBasePriceCent)
usedOwnerCoinPriceCent = minCent(usedOwnerCoinPriceCent, sellerCoinBasePriceCent)
}
}
// 消耗品按申报金额计价,允许超过预收;号主侧按预收消耗品占比线性外推
usedBuyerConsumablePriceCent := maxCent(consumableAmountCent, 0)
@@ -227,6 +238,10 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
consumableUseRatio = maxRatio(float64(usedBuyerConsumablePriceCent)/float64(prepaidConsumablePriceCent), 0)
}
usedOwnerConsumablePriceCent := int64(math.Round(float64(prepaidOwnerConsumablePriceCent) * consumableUseRatio))
if usedBuyerConsumablePriceCent <= prepaidConsumablePriceCent {
usedBuyerConsumablePriceCent = minCent(usedBuyerConsumablePriceCent, prepaidConsumablePriceCent)
usedOwnerConsumablePriceCent = minCent(usedOwnerConsumablePriceCent, prepaidOwnerConsumablePriceCent)
}
actualBuyerRentCent := usedBuyerCoinPriceCent + usedBuyerConsumablePriceCent
actualOwnerRentCent := usedOwnerCoinPriceCent + usedOwnerConsumablePriceCent
@@ -831,3 +831,332 @@ func TestSubmitCheckoutRefreshesStageTime(t *testing.T) {
t.Fatalf("handoff started at = %#v, should be refreshed after old stage time", saved.HandoffStartedAt)
}
}
func TestPlatformManagedOrderOfflineSettlementFlow(t *testing.T) {
db := setupOrderTestDB(t)
repo := NewRepository(db)
adminID := uint64(77)
owner := model.User{Phone: "admin:77"}
renter := model.User{Phone: "13900004001"}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("create owner failed: %v", err)
}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("create renter failed: %v", err)
}
account := model.GameAccount{
OwnerID: owner.ID,
Status: accountStatusRented,
ServerRegion: "国服",
LoginPlatform: "steam",
Title: "平台代管账号",
}
if err := db.Create(&account).Error; err != nil {
t.Fatalf("create account failed: %v", err)
}
listing := model.RentalListing{
ListingNo: "LST-PM-001",
OwnerID: owner.ID,
AccountID: account.ID,
Status: listingStatusRented,
ReviewStatus: listingReviewStatusApproved,
InTransaction: true,
PriceCent: 10000,
HandoffMode: handoffModePlatform,
SettlementMode: settlementModePlatformManaged,
ManagedAdminID: &adminID,
}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("create listing failed: %v", err)
}
order := model.RentalOrder{
OrderNo: "ORD-PM-001",
ListingID: listing.ID,
AccountID: account.ID,
OwnerID: owner.ID,
RenterID: renter.ID,
Status: orderStatusPendingHandoff,
HandoffStatus: handoffStatusPendingOwner,
HandoffMode: handoffModePlatform,
SettlementMode: settlementModePlatformManaged,
ManagedAdminID: &adminID,
RentAmountCent: 10000,
OwnerRentAmountCent: 8000,
PlatformFeeCent: 2000,
DepositAmountCent: 0,
EstimatedDurationHours: 24,
OfflineSettlementStatus: offlineSettlementStatusNone,
AccountSnapshot: datatypes.JSON([]byte(`{"haf_coin_amount":1000000}`)),
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
record, err := repo.AdminPlatformHandoff(t.Context(), adminID, order.ID, PlatformHandoffRequest{
Content: "账号和登录说明",
Reason: "外部上传账号由客服代交接",
}, AuditMeta{})
if err != nil {
t.Fatalf("AdminPlatformHandoff() error = %v", err)
}
if record.Type != "platform_handoff" || record.FromUserID != adminID || record.ToUserID != renter.ID {
t.Fatalf("handoff record = %#v, want platform_handoff from admin to renter", record)
}
if err := repo.ConfirmReceive(t.Context(), renter.ID, order.ID); err != nil {
t.Fatalf("ConfirmReceive() error = %v", err)
}
var handoff model.HandoffRecord
if err := db.Where("order_id = ? AND type = ?", order.ID, "platform_handoff").First(&handoff).Error; err != nil {
t.Fatalf("load platform handoff failed: %v", err)
}
if handoff.ConfirmedByRenterAt == nil {
t.Fatal("platform handoff should be confirmed by renter")
}
if _, err := repo.SubmitCheckout(t.Context(), renter.ID, order.ID, SubmitCheckoutRequest{
Content: "正常结账",
CoinConsumedM: 1,
}); err != nil {
t.Fatalf("SubmitCheckout() error = %v", err)
}
if err := repo.AdminPlatformCheckoutConfirm(t.Context(), adminID, order.ID, AdminActionRequest{Reason: "客服核对无误"}, AuditMeta{}); err != nil {
t.Fatalf("AdminPlatformCheckoutConfirm() error = %v", err)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("load order failed: %v", err)
}
if saved.Status != orderStatusCompleted || saved.SettlementStatus != settlementStatusSettled {
t.Fatalf("status = %s/%s, want completed/settled", saved.Status, saved.SettlementStatus)
}
if saved.OfflineSettlementStatus != offlineSettlementStatusPending {
t.Fatalf("offline settlement status = %q, want pending", saved.OfflineSettlementStatus)
}
if saved.OfflineSettlementAmountCent != 8000 {
t.Fatalf("offline settlement amount = %d, want 8000", saved.OfflineSettlementAmountCent)
}
if saved.OwnerSettledAt != nil {
t.Fatalf("owner settled at = %#v, want nil before offline settlement", saved.OwnerSettledAt)
}
var ownerLedgerCount int64
if err := db.Model(&model.WalletLedger{}).Where("user_id = ?", owner.ID).Count(&ownerLedgerCount).Error; err != nil {
t.Fatalf("count wallet ledger failed: %v", err)
}
if ownerLedgerCount != 0 {
t.Fatalf("owner wallet ledger count = %d, want 0", ownerLedgerCount)
}
dto, err := repo.FindAdmin(t.Context(), order.ID)
if err != nil {
t.Fatalf("FindAdmin() error = %v", err)
}
if dto.AdminActions == nil || dto.AdminActions.PlatformOfflineSettlement == nil {
t.Fatal("admin dto should expose offline settlement action")
}
if err := repo.AdminMarkOfflineSettlement(t.Context(), adminID, order.ID, OfflineSettlementRequest{Remark: "支付宝线下转账"}, AuditMeta{}); err != nil {
t.Fatalf("AdminMarkOfflineSettlement() error = %v", err)
}
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("reload order failed: %v", err)
}
if saved.OfflineSettlementStatus != offlineSettlementStatusSettled {
t.Fatalf("offline settlement status = %q, want settled", saved.OfflineSettlementStatus)
}
if saved.OfflineSettledBy == nil || *saved.OfflineSettledBy != adminID {
t.Fatalf("offline settled by = %#v, want %d", saved.OfflineSettledBy, adminID)
}
if saved.OfflineSettledAt == nil || saved.OwnerSettledAt == nil {
t.Fatalf("offline settled at = %#v, owner settled at = %#v, want both set", saved.OfflineSettledAt, saved.OwnerSettledAt)
}
if saved.OfflineSettlementRemark != "支付宝线下转账" {
t.Fatalf("offline settlement remark = %q", saved.OfflineSettlementRemark)
}
if err := db.Model(&model.WalletLedger{}).Where("user_id = ?", owner.ID).Count(&ownerLedgerCount).Error; err != nil {
t.Fatalf("recount wallet ledger failed: %v", err)
}
if ownerLedgerCount != 0 {
t.Fatalf("owner wallet ledger count after offline settlement = %d, want 0", ownerLedgerCount)
}
}
func TestAdminPlatformCheckoutCounterCanBeAcceptedByRenter(t *testing.T) {
db := setupOrderTestDB(t)
repo := NewRepository(db)
adminID := uint64(78)
_, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
checkout := createOpenPlatformCheckout(t, db, order, renter.ID)
dto, err := repo.AdminPlatformCheckoutCounter(t.Context(), adminID, order.ID, CounterCheckoutRequest{
CoinConsumedM: 1,
DepositDeductAmountCent: 1000,
Reason: "客服核对后增加押金赔付",
}, AuditMeta{})
if err != nil {
t.Fatalf("AdminPlatformCheckoutCounter() error = %v", err)
}
if dto.ID != checkout.ID || dto.Turn != checkoutTurnRenter {
t.Fatalf("checkout dto = %#v, want same checkout and renter turn", dto)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("load order failed: %v", err)
}
if saved.Status != orderStatusPendingCheckoutAccept || saved.HandoffStatus != handoffStatusPendingRenterCheckout {
t.Fatalf("status = %s/%s, want pending_checkout_accept/pending_renter_checkout", saved.Status, saved.HandoffStatus)
}
var savedCheckout model.OrderCheckout
if err := db.First(&savedCheckout, checkout.ID).Error; err != nil {
t.Fatalf("load checkout failed: %v", err)
}
if savedCheckout.Status != checkoutStatusCountered || savedCheckout.ProposedBy != adminID {
t.Fatalf("checkout status/proposed = %s/%d, want countered/%d", savedCheckout.Status, savedCheckout.ProposedBy, adminID)
}
if savedCheckout.DepositDeductAmountCent != 1000 {
t.Fatalf("deposit deduct = %d, want 1000", savedCheckout.DepositDeductAmountCent)
}
if err := repo.AcceptCheckout(t.Context(), renter.ID, order.ID); err != nil {
t.Fatalf("AcceptCheckout() error = %v", err)
}
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("reload order failed: %v", err)
}
if saved.Status != orderStatusCompleted || saved.OfflineSettlementStatus != offlineSettlementStatusPending {
t.Fatalf("status/offline = %s/%s, want completed/pending", saved.Status, saved.OfflineSettlementStatus)
}
if saved.OfflineSettlementAmountCent != 9000 {
t.Fatalf("offline settlement amount = %d, want 9000", saved.OfflineSettlementAmountCent)
}
}
func TestAdminPlatformCheckoutDisputeCreatesArbitrationCase(t *testing.T) {
db := setupOrderTestDB(t)
if err := db.AutoMigrate(&model.Dispute{}); err != nil {
t.Fatalf("migrate dispute failed: %v", err)
}
repo := NewRepository(db)
adminID := uint64(79)
_, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
checkout := createOpenPlatformCheckout(t, db, order, renter.ID)
if err := repo.AdminPlatformCheckoutDispute(t.Context(), adminID, order.ID, AdminActionRequest{Reason: "租客与客服对结账金额有异议"}, AuditMeta{}); err != nil {
t.Fatalf("AdminPlatformCheckoutDispute() error = %v", err)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("load order failed: %v", err)
}
if saved.Status != orderStatusCheckoutDisputing || saved.HandoffStatus != handoffStatusCheckoutDisputed || saved.SettlementStatus != settlementStatusDisputed {
t.Fatalf("status = %s/%s/%s, want checkout_disputing/checkout_disputed/disputed", saved.Status, saved.HandoffStatus, saved.SettlementStatus)
}
var savedCheckout model.OrderCheckout
if err := db.First(&savedCheckout, checkout.ID).Error; err != nil {
t.Fatalf("load checkout failed: %v", err)
}
if savedCheckout.Status != checkoutStatusDisputed {
t.Fatalf("checkout status = %s, want disputed", savedCheckout.Status)
}
var dispute model.Dispute
if err := db.Where("order_id = ?", order.ID).First(&dispute).Error; err != nil {
t.Fatalf("load dispute failed: %v", err)
}
if dispute.InitiatorID != adminID || dispute.TargetUserID != renter.ID || dispute.Type != "checkout_dispute" {
t.Fatalf("dispute = %#v, want admin initiated checkout dispute to renter", dispute)
}
if dispute.CheckoutID == nil || *dispute.CheckoutID != checkout.ID || dispute.PreviousCheckoutStatus != checkoutStatusSubmitted {
t.Fatalf("dispute checkout snapshot = %#v/%s, want checkout %d submitted", dispute.CheckoutID, dispute.PreviousCheckoutStatus, checkout.ID)
}
dto, err := repo.FindAdmin(t.Context(), order.ID)
if err != nil {
t.Fatalf("FindAdmin() error = %v", err)
}
if dto.ActiveDispute == nil || dto.ActiveDispute.ID != dispute.ID {
t.Fatalf("active dispute = %#v, want dispute %d", dto.ActiveDispute, dispute.ID)
}
}
func createPlatformManagedCheckoutOrder(t *testing.T, db *gorm.DB, adminID uint64) (model.User, model.User, model.RentalOrder) {
t.Helper()
owner := model.User{Phone: "admin:checkout"}
renter := model.User{Phone: "13900004002"}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("create owner failed: %v", err)
}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("create renter failed: %v", err)
}
account := model.GameAccount{
OwnerID: owner.ID,
Status: accountStatusRented,
ServerRegion: "国服",
LoginPlatform: "steam",
Title: "平台代管结账账号",
}
if err := db.Create(&account).Error; err != nil {
t.Fatalf("create account failed: %v", err)
}
listing := model.RentalListing{
ListingNo: "LST-PM-CHECKOUT",
OwnerID: owner.ID,
AccountID: account.ID,
Status: listingStatusRented,
ReviewStatus: listingReviewStatusApproved,
InTransaction: true,
PriceCent: 10000,
HandoffMode: handoffModePlatform,
SettlementMode: settlementModePlatformManaged,
ManagedAdminID: &adminID,
}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("create listing failed: %v", err)
}
order := model.RentalOrder{
OrderNo: "ORD-PM-CHECKOUT",
ListingID: listing.ID,
AccountID: account.ID,
OwnerID: owner.ID,
RenterID: renter.ID,
Status: orderStatusPendingCheckoutConfirm,
HandoffStatus: handoffStatusPendingOwnerCheckout,
HandoffMode: handoffModePlatform,
SettlementMode: settlementModePlatformManaged,
ManagedAdminID: &adminID,
RentAmountCent: 10000,
OwnerRentAmountCent: 8000,
PlatformFeeCent: 2000,
DepositAmountCent: 1000,
EstimatedDurationHours: 24,
OfflineSettlementStatus: offlineSettlementStatusNone,
AccountSnapshot: datatypes.JSON([]byte(`{
"haf_coin_amount":1000000,
"asset_summary":{"price_breakdown":{"buyer_coin_base_price":100,"seller_coin_base_price":80,"consumable_price":0}}
}`)),
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
return owner, renter, order
}
func createOpenPlatformCheckout(t *testing.T, db *gorm.DB, order model.RentalOrder, renterID uint64) model.OrderCheckout {
t.Helper()
checkout, err := buildCheckout(order, renterID, checkoutStatusSubmitted, "租客申请结账", nil, 0, 1, 0, 0, true)
if err != nil {
t.Fatalf("build checkout failed: %v", err)
}
checkout.RoundCount = 1
checkout.Turn = checkoutTurnOwner
checkout.ProposedBy = renterID
if err := db.Create(&checkout).Error; err != nil {
t.Fatalf("create checkout failed: %v", err)
}
return checkout
}
@@ -233,6 +233,42 @@ func TestCalculateCheckoutSettlementUsesExplicitRatiosForActualConsume(t *testin
}
}
func TestCalculateCheckoutSettlementCapsFullUseRatioRounding(t *testing.T) {
// 发布量刚好用完时,比例换算的四舍五入不能把线下待打款抬高。
order := model.RentalOrder{
RentAmountCent: 37210,
OwnerRentAmountCent: 34000,
PlatformFeeCent: 3210,
DepositAmountCent: 50000,
AccountSnapshot: datatypes.JSON([]byte(`{
"haf_coin_amount": 160000000,
"asset_summary": {
"price_breakdown": {
"buyer_ratio": 43,
"seller_ratio": 47,
"buyer_coin_base_price": 372.1,
"seller_coin_base_price": 340,
"consumable_price": 0
}
}
}`)),
}
settlement := calculateCheckoutSettlement(order, 0, 160, 0)
if settlement.ActualRentAmountCent != order.RentAmountCent {
t.Fatalf("ActualRentAmountCent = %d, want %d", settlement.ActualRentAmountCent, order.RentAmountCent)
}
if settlement.OwnerRentIncomeCent != order.OwnerRentAmountCent {
t.Fatalf("OwnerRentIncomeCent = %d, want %d", settlement.OwnerRentIncomeCent, order.OwnerRentAmountCent)
}
if settlement.PlatformFeeCent != order.PlatformFeeCent {
t.Fatalf("PlatformFeeCent = %d, want %d", settlement.PlatformFeeCent, order.PlatformFeeCent)
}
if settlement.OvershootAmountCent != 0 {
t.Fatalf("OvershootAmountCent = %d, want 0", settlement.OvershootAmountCent)
}
}
func TestCalculateDepositWaiverUsesSharedRemainingQuota(t *testing.T) {
paid, waived := calculateDepositWaiver(500, 300, 0)
if paid != 200 || waived != 300 {
+75 -23
View File
@@ -6,29 +6,31 @@ import (
)
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidRentHours = errors.New("invalid rent hours")
ErrListingUnavailable = errors.New("listing unavailable")
ErrCannotRentOwnListing = errors.New("cannot rent own listing")
ErrInsufficientBalance = errors.New("insufficient balance")
ErrOrderCannotPay = errors.New("order cannot pay")
ErrChannelPaymentRequired = errors.New("channel payment required")
ErrOrderCannotCancel = errors.New("order cannot cancel")
ErrOrderCannotHandoff = errors.New("order cannot handoff")
ErrOrderCannotResetHandoff = errors.New("order cannot reset handoff")
ErrOrderCannotReceive = errors.New("order cannot receive")
ErrOrderCannotReturn = errors.New("order cannot return")
ErrOrderCannotComplete = errors.New("order cannot complete")
ErrCheckoutCannotSubmit = errors.New("checkout cannot submit")
ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm")
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
ErrCheckoutMaxRounds = errors.New("checkout max rounds reached")
ErrCheckoutDepositShortfall = errors.New("checkout deposit shortfall")
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
ErrPermissionDenied = errors.New("permission denied")
ErrDepositCannotHold = errors.New("deposit cannot hold")
ErrDepositNotHeld = errors.New("deposit not held")
ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty")
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidRentHours = errors.New("invalid rent hours")
ErrListingUnavailable = errors.New("listing unavailable")
ErrCannotRentOwnListing = errors.New("cannot rent own listing")
ErrInsufficientBalance = errors.New("insufficient balance")
ErrOrderCannotPay = errors.New("order cannot pay")
ErrChannelPaymentRequired = errors.New("channel payment required")
ErrOrderCannotCancel = errors.New("order cannot cancel")
ErrOrderCannotHandoff = errors.New("order cannot handoff")
ErrOrderCannotResetHandoff = errors.New("order cannot reset handoff")
ErrOrderCannotReceive = errors.New("order cannot receive")
ErrOrderCannotReturn = errors.New("order cannot return")
ErrOrderCannotComplete = errors.New("order cannot complete")
ErrCheckoutCannotSubmit = errors.New("checkout cannot submit")
ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm")
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
ErrCheckoutMaxRounds = errors.New("checkout max rounds reached")
ErrCheckoutDepositShortfall = errors.New("checkout deposit shortfall")
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
ErrDisputeExists = errors.New("dispute already exists")
ErrPermissionDenied = errors.New("permission denied")
ErrDepositCannotHold = errors.New("deposit cannot hold")
ErrDepositNotHeld = errors.New("deposit not held")
ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty")
ErrOfflineSettlementCannotMark = errors.New("offline settlement cannot mark")
)
const internalOrderHours = 24
@@ -221,6 +223,56 @@ func (s *Service) AdminResetHandoff(ctx context.Context, adminID uint64, orderID
return s.repo.AdminResetHandoff(ctx, adminID, orderID, req, meta)
}
func (s *Service) AdminPlatformHandoff(ctx context.Context, adminID uint64, orderID uint64, req PlatformHandoffRequest, meta AuditMeta) (*HandoffRecordDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if orderID == 0 || req.Content == "" || req.Reason == "" {
return nil, ErrOrderCannotHandoff
}
return s.repo.AdminPlatformHandoff(ctx, adminID, orderID, req, meta)
}
func (s *Service) AdminPlatformCheckoutConfirm(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
if orderID == 0 || req.Reason == "" {
return ErrCheckoutCannotConfirm
}
return s.repo.AdminPlatformCheckoutConfirm(ctx, adminID, orderID, req, meta)
}
func (s *Service) AdminPlatformCheckoutCounter(ctx context.Context, adminID uint64, orderID uint64, req CounterCheckoutRequest, meta AuditMeta) (*CheckoutDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if orderID == 0 || req.Reason == "" {
return nil, ErrCheckoutCannotCounter
}
return s.repo.AdminPlatformCheckoutCounter(ctx, adminID, orderID, req, meta)
}
func (s *Service) AdminPlatformCheckoutDispute(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
if orderID == 0 || req.Reason == "" {
return ErrCheckoutCannotCounter
}
return s.repo.AdminPlatformCheckoutDispute(ctx, adminID, orderID, req, meta)
}
func (s *Service) AdminMarkOfflineSettlement(ctx context.Context, adminID uint64, orderID uint64, req OfflineSettlementRequest, meta AuditMeta) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
if orderID == 0 {
return ErrOfflineSettlementCannotMark
}
return s.repo.AdminMarkOfflineSettlement(ctx, adminID, orderID, req, meta)
}
func (s *Service) AdminHoldDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
if s.repo == nil {
return ErrDependencyUnavailable