Files
hfb_sys/backend/internal/modules/dispute/cancel.go
T
yml2213 d3d571357a refactor(order): 结账单状态常量化并锁定仲裁结单统计口径
仲裁结案会把结账单置为终态 settled,但该状态一直是裸字符串;同时 adminfinance 按日结算聚合只按
accepted 过滤,容易被误读成"仲裁单掉出统计"。本次只做卫生与口径固化,不改任何行为:
- order 新增 CheckoutStatus{Submitted,Countered,Accepted,Disputed,Settled},包内沿用非导出别名
- dispute 的仲裁结案与争议取消分支改用常量;distribution 不能反向依赖 order,本地声明同名常量
- adminfinance 聚合里的 oc.status = 'accepted' 改为常量绑定,并注释说明仲裁单不计入是有意口径
- 新增 distribution 结账单退款重算用例(该分支原先无覆盖):accepted/settled 按结账单实际租金,
  submitted 不被采用;dashboard 用例补一张 settled 结账单,钉住"不计入已结算订单数"
2026-09-16 12:49:21 +08:00

187 lines
5.4 KiB
Go

package dispute
import (
"context"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification"
ordermodule "hfb_sys/backend/internal/modules/order"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (r *Repository) CancelByOrder(ctx context.Context, userID uint64, orderID uint64) (*DisputeDTO, error) {
var row model.Dispute
if err := r.db.WithContext(ctx).
Where("order_id = ? AND initiator_type = ? AND initiator_id = ? AND status IN ?", orderID, disputeInitiatorUser, userID, []string{"open", "processing"}).
Order("id DESC").
First(&row).Error; err != nil {
return nil, err
}
return r.Cancel(ctx, userID, row.ID)
}
func (r *Repository) Cancel(ctx context.Context, userID uint64, id uint64) (*DisputeDTO, error) {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var row model.Dispute
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
return err
}
if effectiveInitiatorType(row) != disputeInitiatorUser || row.InitiatorID != userID {
return ErrPermissionDenied
}
if row.Status != "open" && row.Status != "processing" {
return ErrDisputeCannotCancel
}
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, row.OrderID).Error; err != nil {
return err
}
if err := restoreOrderAfterDisputeCancel(tx, &row, &order); err != nil {
return err
}
now := time.Now()
row.Status = "cancelled"
row.ArbitrationRemark = "用户自行取消申诉"
row.HandledAt = &now
if err := tx.Save(&row).Error; err != nil {
return err
}
if err := tx.Save(&order).Error; err != nil {
return err
}
recordType := "dispute_cancelled"
recordContent := "用户自行取消申诉,订单已恢复到申诉前流程。"
if row.Type == "checkout_dispute" {
recordType = "checkout_dispute_cancelled"
recordContent = "用户自行取消结账争议,订单已恢复到申诉前流程。"
}
record := model.HandoffRecord{
OrderID: order.ID,
FromUserID: row.InitiatorID,
ToUserID: row.TargetUserID,
Type: recordType,
Content: recordContent,
}
if err := tx.Create(&record).Error; err != nil {
return err
}
disputeID := row.ID
if err := notification.Append(tx,
notification.Entry{
UserID: row.TargetUserID,
Type: "dispute",
Title: "申诉已取消",
Content: "对方已自行取消申诉,订单已恢复到申诉前流程。",
BizType: "dispute",
BizID: &disputeID,
},
notification.Entry{
UserID: row.InitiatorID,
Type: "dispute",
Title: "申诉已取消",
Content: "你已取消申诉,订单已恢复到申诉前流程。",
BizType: "dispute",
BizID: &disputeID,
},
); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
var row disputeRow
if err := r.baseQuery(ctx).Where("d.id = ?", id).First(&row).Error; err != nil {
return nil, err
}
dto := row.toDTO()
return &dto, nil
}
func restoreOrderAfterDisputeCancel(tx *gorm.DB, row *model.Dispute, order *model.RentalOrder) error {
if row.Type == "checkout_dispute" {
return restoreCheckoutDispute(tx, row, order)
}
if order.Status != "disputing" {
return ErrDisputeCannotCancel
}
restoreOrderStatusFromSnapshot(row, order)
if order.Status == "" || order.Status == "disputing" {
order.Status = inferOrderStatusForDisputeCancel(*order)
}
if order.Status == "" {
return ErrDisputeCannotCancel
}
return nil
}
func restoreCheckoutDispute(tx *gorm.DB, row *model.Dispute, order *model.RentalOrder) error {
if order.Status != "checkout_disputing" {
return ErrDisputeCannotCancel
}
var checkout model.OrderCheckout
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_id = ? AND status = ?", order.ID, "disputed")
if row.CheckoutID != nil && *row.CheckoutID > 0 {
query = query.Where("id = ?", *row.CheckoutID)
}
if err := query.Order("id DESC").First(&checkout).Error; err != nil {
return err
}
restoreOrderStatusFromSnapshot(row, order)
if order.Status == "" || order.Status == "checkout_disputing" {
order.Status = "pending_checkout_confirm"
order.HandoffStatus = "pending_owner_checkout"
if checkout.OwnerAdjustedAt != nil || checkout.OwnerAdjustmentReason != "" {
order.Status = "pending_checkout_accept"
order.HandoffStatus = "pending_renter_checkout"
}
order.SettlementStatus = "pending"
}
checkout.Status = row.PreviousCheckoutStatus
if checkout.Status == "" {
checkout.Status = ordermodule.CheckoutStatusSubmitted
if order.Status == "pending_checkout_accept" {
checkout.Status = ordermodule.CheckoutStatusCountered
}
}
checkout.RenterRejectedAt = nil
if err := tx.Save(&checkout).Error; err != nil {
return err
}
return nil
}
func restoreOrderStatusFromSnapshot(row *model.Dispute, order *model.RentalOrder) {
if row.PreviousOrderStatus != "" {
order.Status = row.PreviousOrderStatus
}
if row.PreviousHandoffStatus != "" {
order.HandoffStatus = row.PreviousHandoffStatus
}
if row.PreviousSettlementStatus != "" {
order.SettlementStatus = row.PreviousSettlementStatus
}
}
func inferOrderStatusForDisputeCancel(order model.RentalOrder) string {
switch order.HandoffStatus {
case "none":
return "pending_payment"
case "pending_owner", "pending_renter_confirm":
return "pending_handoff"
case "received":
return "renting"
case "return_overdue":
return "overdue"
default:
return ""
}
}