完善订单交接结账流程留痕

This commit is contained in:
yml2213
2026-08-28 23:48:32 +08:00
parent a9a9127076
commit c87883cc65
27 changed files with 915 additions and 5 deletions
+3
View File
@@ -38,8 +38,10 @@ func NewTestDBWithName(name string) *gorm.DB {
func MigrateListingLifecycleTestSchema(db *gorm.DB) error { func MigrateListingLifecycleTestSchema(db *gorm.DB) error {
return db.AutoMigrate( return db.AutoMigrate(
&model.User{}, &model.User{},
&model.AdminUser{},
&model.GameAccount{}, &model.GameAccount{},
&model.RentalListing{}, &model.RentalListing{},
&model.ListingUpload{},
&model.ListingStatusEvent{}, &model.ListingStatusEvent{},
) )
} }
@@ -61,6 +63,7 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error {
&model.WalletLedger{}, &model.WalletLedger{},
&model.RenterGrowthLedger{}, &model.RenterGrowthLedger{},
&model.AuditLog{}, &model.AuditLog{},
&model.ProcessEvent{},
&model.ChatConversation{}, &model.ChatConversation{},
&model.ChatParticipant{}, &model.ChatParticipant{},
&model.ChatAdminConversationState{}, &model.ChatAdminConversationState{},
+2
View File
@@ -41,6 +41,8 @@ type RentalOrder struct {
GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"` GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"` GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"` AccountSnapshot datatypes.JSON `json:"account_snapshot"`
AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"`
SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"`
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"` Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"` HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"` HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
+34
View File
@@ -0,0 +1,34 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// ProcessEvent 是订单、提号等交易流程的不可变操作留痕。
// 当前业务表仍保存当前状态;此表只追加,用于还原每一步由谁填写、修改和确认。
type ProcessEvent struct {
ID uint64 `gorm:"primaryKey" json:"id"`
BusinessType string `gorm:"size:32;not null;index:idx_process_event_business" json:"business_type"`
BusinessID uint64 `gorm:"not null;index:idx_process_event_business" json:"business_id"`
Stage string `gorm:"size:32;not null;default:''" json:"stage"`
Action string `gorm:"size:64;not null" json:"action"`
ActorType string `gorm:"size:16;not null" json:"actor_type"`
ActorID uint64 `gorm:"not null;default:0" json:"actor_id"`
ActorName string `gorm:"size:128;not null;default:''" json:"actor_name"`
TargetType string `gorm:"size:16;not null;default:''" json:"target_type"`
TargetID *uint64 `json:"target_id,omitempty"`
TargetName string `gorm:"size:128;not null;default:''" json:"target_name"`
Content string `gorm:"type:text" json:"content"`
Reason string `gorm:"type:text" json:"reason"`
Payload datatypes.JSON `gorm:"not null" json:"payload"`
AttachmentURLs datatypes.JSON `gorm:"column:attachment_urls;not null" json:"attachment_urls"`
StateBefore datatypes.JSON `gorm:"column:state_before;not null" json:"state_before"`
StateAfter datatypes.JSON `gorm:"column:state_after;not null" json:"state_after"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
func (ProcessEvent) TableName() string {
return "process_events"
}
+25 -2
View File
@@ -6,6 +6,7 @@ import (
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -31,9 +32,12 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
if hasOpen { if hasOpen {
return ErrCheckoutCannotSubmit return ErrCheckoutCannotSubmit
} }
before := orderState(order)
checkoutToUserID := order.OwnerID checkoutToUserID := order.OwnerID
checkoutTargetType := processlog.ActorUser
if isPlatformSettlementOrder(order) && platformManagedAdminID(order) > 0 { if isPlatformSettlementOrder(order) && platformManagedAdminID(order) > 0 {
checkoutToUserID = platformManagedAdminID(order) checkoutToUserID = platformManagedAdminID(order)
checkoutTargetType = processlog.ActorAdmin
} }
record := model.HandoffRecord{ record := model.HandoffRecord{
OrderID: order.ID, OrderID: order.ID,
@@ -61,6 +65,9 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID
order.SettlementStatus = settlementStatusPending order.SettlementStatus = settlementStatusPending
now := time.Now() now := time.Now()
order.HandoffStartedAt = &now order.HandoffStartedAt = &now
if err := appendOrderEvent(tx, order, "checkout", "checkout_submitted", processlog.ActorUser, userID, checkoutTargetType, &checkoutToUserID, req.Content, "", checkoutEventPayload(checkout), before, req.EvidenceURLS); err != nil {
return err
}
orderID := order.ID orderID := order.ID
if isPlatformSettlementOrder(order) { if isPlatformSettlementOrder(order) {
if err := appendManagedAdminNotification(tx, order, "checkout", "代管订单待确认结账", "租客已发起结账,请检查账号状态和消耗明细后确认。"); err != nil { if err := appendManagedAdminNotification(tx, order, "checkout", "代管订单待确认结账", "租客已发起结账,请检查账号状态和消耗明细后确认。"); err != nil {
@@ -108,6 +115,7 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout { if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout {
return ErrCheckoutCannotConfirm return ErrCheckoutCannotConfirm
} }
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID) checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil { if err != nil {
return err return err
@@ -128,7 +136,10 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
checkout.OwnerAdjustedAt = &now checkout.OwnerAdjustedAt = &now
action, err := r.finalizeCheckout(tx, &order, checkout, "号主已确认结账,订单完成。") action, err := r.finalizeCheckout(tx, &order, checkout, "号主已确认结账,订单完成。")
refund = action refund = action
return err if err != nil {
return err
}
return appendOrderEvent(tx, order, "checkout", "checkout_confirmed", processlog.ActorUser, userID, processlog.ActorUser, &order.RenterID, "号主确认当前结账方案,订单完成。", "", checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS))
}) })
if err != nil { if err != nil {
return err return err
@@ -154,6 +165,7 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingCheckoutConfirm && order.Status != orderStatusPendingCheckoutAccept { if order.Status != orderStatusPendingCheckoutConfirm && order.Status != orderStatusPendingCheckoutAccept {
return ErrCheckoutCannotCounter return ErrCheckoutCannotCounter
} }
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID) checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil { if err != nil {
return err return err
@@ -221,6 +233,13 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
if err := tx.Save(&order).Error; err != nil { if err := tx.Save(&order).Error; err != nil {
return err return err
} }
targetID := order.OwnerID
if isOwner {
targetID = order.RenterID
}
if err := appendOrderEvent(tx, order, "checkout", "checkout_countered", processlog.ActorUser, userID, processlog.ActorUser, &targetID, "", req.Reason, checkoutEventPayload(*checkout), before, req.EvidenceURLS); err != nil {
return err
}
if err := tx.Save(checkout).Error; err != nil { if err := tx.Save(checkout).Error; err != nil {
return err return err
} }
@@ -285,6 +304,7 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingCheckoutAccept || order.HandoffStatus != handoffStatusPendingRenterCheckout { if order.Status != orderStatusPendingCheckoutAccept || order.HandoffStatus != handoffStatusPendingRenterCheckout {
return ErrCheckoutCannotConfirm return ErrCheckoutCannotConfirm
} }
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID) checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil { if err != nil {
return err return err
@@ -300,7 +320,10 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID
checkout.RenterConfirmedAt = &now checkout.RenterConfirmedAt = &now
action, err := r.finalizeCheckout(tx, &order, checkout, "租客已确认结账协商,订单完成。") action, err := r.finalizeCheckout(tx, &order, checkout, "租客已确认结账协商,订单完成。")
refund = action refund = action
return err if err != nil {
return err
}
return appendOrderEvent(tx, order, "checkout", "checkout_accepted", processlog.ActorUser, userID, processlog.ActorUser, &order.OwnerID, "租客接受当前结账方案,订单完成。", "", checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS))
}) })
if err != nil { if err != nil {
return err return err
@@ -5,6 +5,7 @@ import (
"time" "time"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -23,6 +24,7 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order
if !canHoldDeposit(order) { if !canHoldDeposit(order) {
return ErrDepositCannotHold return ErrDepositCannotHold
} }
before := orderState(order)
beforeStatus := order.DepositHoldStatus beforeStatus := order.DepositHoldStatus
now := time.Now() now := time.Now()
order.DepositHoldStatus = depositHoldStatusHeld order.DepositHoldStatus = depositHoldStatusHeld
@@ -30,6 +32,9 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order
order.DepositHeldBy = &adminID order.DepositHeldBy = &adminID
order.DepositHeldAt = &now order.DepositHeldAt = &now
order.DepositHoldReleasedAt = nil order.DepositHoldReleasedAt = nil
if err := appendOrderEvent(tx, order, "settlement", "deposit_held", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服暂扣订单押金。", req.Reason, map[string]any{"deposit_amount_cent": order.DepositAmountCent}, before, nil); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.deposit_hold", "order", order.ID, meta, map[string]any{ if err := appendAuditLog(tx, adminID, "order.deposit_hold", "order", order.ID, meta, map[string]any{
"order_id": order.ID, "order_id": order.ID,
"order_no": order.OrderNo, "order_no": order.OrderNo,
@@ -61,9 +66,13 @@ func (r *Repository) AdminReleaseDeposit(ctx context.Context, adminID uint64, or
if holdAmountCent <= 0 { if holdAmountCent <= 0 {
return ErrDepositHoldAmountEmpty return ErrDepositHoldAmountEmpty
} }
before := orderState(order)
now := time.Now() now := time.Now()
order.DepositHoldStatus = depositHoldStatusReleased order.DepositHoldStatus = depositHoldStatusReleased
order.DepositHoldReleasedAt = &now order.DepositHoldReleasedAt = &now
if err := appendOrderEvent(tx, order, "settlement", "deposit_released", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服归还已暂扣的押金。", req.Reason, map[string]any{"deposit_hold_amount_cent": holdAmountCent}, before, nil); err != nil {
return err
}
action, err := r.prepareRefund(&order, holdAmountCent, refundBizDeposit, "暂扣押金归还原路退回") action, err := r.prepareRefund(&order, holdAmountCent, refundBizDeposit, "暂扣押金归还原路退回")
if err != nil { if err != nil {
return err return err
+24
View File
@@ -51,6 +51,8 @@ type OrderDTO struct {
GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"` GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"` GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"` AccountSnapshot datatypes.JSON `json:"account_snapshot"`
AccountSource string `json:"account_source"`
SourceChannel string `json:"source_channel,omitempty"`
Status string `json:"status"` Status string `json:"status"`
HandoffStatus string `json:"handoff_status"` HandoffStatus string `json:"handoff_status"`
HandoffMode string `json:"handoff_mode"` HandoffMode string `json:"handoff_mode"`
@@ -190,6 +192,28 @@ type HandoffRecordDTO struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// ProcessEventDTO 是后台交易时间线中的一条不可变过程记录。
type ProcessEventDTO struct {
ID uint64 `json:"id"`
BusinessType string `json:"business_type"`
BusinessID uint64 `json:"business_id"`
Stage string `json:"stage"`
Action string `json:"action"`
ActorType string `json:"actor_type"`
ActorID uint64 `json:"actor_id"`
ActorName string `json:"actor_name"`
TargetType string `json:"target_type"`
TargetID *uint64 `json:"target_id,omitempty"`
TargetName string `json:"target_name"`
Content string `json:"content"`
Reason string `json:"reason"`
Payload datatypes.JSON `json:"payload"`
AttachmentURLs []string `json:"attachment_urls"`
StateBefore datatypes.JSON `json:"state_before"`
StateAfter datatypes.JSON `json:"state_after"`
CreatedAt time.Time `json:"created_at"`
}
type CheckoutDTO struct { type CheckoutDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"` OrderID uint64 `json:"order_id"`
@@ -7,6 +7,7 @@ import (
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -51,6 +52,7 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
if !canAdminForceHandoff(order) || strings.TrimSpace(req.Reason) == "" { if !canAdminForceHandoff(order) || strings.TrimSpace(req.Reason) == "" {
return ErrOrderCannotForceHandoff return ErrOrderCannotForceHandoff
} }
before := orderState(order)
if order.RefundStatus == refundStatusPending || order.RefundStatus == refundStatusPendingReview { if order.RefundStatus == refundStatusPending || order.RefundStatus == refundStatusPendingReview {
return ErrOrderCannotForceHandoff return ErrOrderCannotForceHandoff
} }
@@ -100,6 +102,9 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde
order.HandoffStatus = handoffStatusReceived order.HandoffStatus = handoffStatusReceived
order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot) order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot)
order.RentedAt = &now order.RentedAt = &now
if err := appendOrderEvent(tx, order, "handoff", "admin_force_handoff", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, content, req.Reason, nil, before, nil); err != nil {
return err
}
orderID := order.ID orderID := order.ID
if err := notification.Append(tx, if err := notification.Append(tx,
notification.Entry{ notification.Entry{
@@ -60,6 +60,19 @@ func (h *Handler) AdminHandoffRecords(c *gin.Context) {
response.OK(c, gin.H{"items": items}) response.OK(c, gin.H{"items": items})
} }
func (h *Handler) AdminProcessEvents(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
items, err := h.service.ProcessEventsAdmin(c.Request.Context(), id)
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) AdminClose(c *gin.Context) { func (h *Handler) AdminClose(c *gin.Context) {
h.adminAction(c, h.service.AdminClose, gin.H{"closed": true}) h.adminAction(c, h.service.AdminClose, gin.H{"closed": true})
} }
@@ -5,6 +5,7 @@ import (
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -20,6 +21,7 @@ func (r *Repository) SubmitHandoff(ctx context.Context, userID uint64, orderID u
if order.OwnerID != userID { if order.OwnerID != userID {
return ErrPermissionDenied return ErrPermissionDenied
} }
before := orderState(order)
// 号主交接超时(owner_timeout)后仍允许补提交,避免临时延误导致订单卡死。 // 号主交接超时(owner_timeout)后仍允许补提交,避免临时延误导致订单卡死。
if order.Status != orderStatusPendingHandoff || if order.Status != orderStatusPendingHandoff ||
(order.HandoffStatus != handoffStatusPendingOwner && order.HandoffStatus != handoffStatusOwnerTimeout) { (order.HandoffStatus != handoffStatusPendingOwner && order.HandoffStatus != handoffStatusOwnerTimeout) {
@@ -37,6 +39,9 @@ func (r *Repository) SubmitHandoff(ctx context.Context, userID uint64, orderID u
return err return err
} }
order.HandoffStatus = handoffStatusPendingRenterConfirm order.HandoffStatus = handoffStatusPendingRenterConfirm
if err := appendOrderEvent(tx, order, "handoff", "owner_handoff_submitted", processlog.ActorUser, userID, processlog.ActorUser, &order.RenterID, req.Content, "", nil, before, nil); err != nil {
return err
}
orderID := order.ID orderID := order.ID
renterContent := "请查看交接记录,确认账号可正常登录后点击确认收号。" renterContent := "请查看交接记录,确认账号可正常登录后点击确认收号。"
if lateSubmit { if lateSubmit {
@@ -9,6 +9,7 @@ import (
"hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/chat"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/rentergrowth" "hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/internal/processlog"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
@@ -44,6 +45,10 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
if err != nil { if err != nil {
return err return err
} }
accountSource, sourceChannel, err := orderAccountSourceSnapshot(tx, listing.ID)
if err != nil {
return err
}
orderNo, err := newOrderNo() orderNo, err := newOrderNo()
if err != nil { if err != nil {
return err return err
@@ -89,6 +94,8 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
RenterGrowthLevelName: growthSnapshot.LevelName, RenterGrowthLevelName: growthSnapshot.LevelName,
RenterDiscountBps: growthSnapshot.DiscountBps, RenterDiscountBps: growthSnapshot.DiscountBps,
AccountSnapshot: snapshot, AccountSnapshot: snapshot,
AccountSource: accountSource,
SourceChannel: sourceChannel,
Status: orderStatusPendingPayment, Status: orderStatusPendingPayment,
HandoffStatus: handoffStatusNone, HandoffStatus: handoffStatusNone,
HandoffMode: listingHandoffMode(listing), HandoffMode: listingHandoffMode(listing),
@@ -126,6 +133,18 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
return r.FindForUser(ctx, renterID, createdID) return r.FindForUser(ctx, renterID, createdID)
} }
func orderAccountSourceSnapshot(tx *gorm.DB, listingID uint64) (string, string, error) {
var upload model.ListingUpload
err := tx.Where("listing_id = ?", listingID).Order("id DESC").First(&upload).Error
if err == gorm.ErrRecordNotFound {
return "internal", "", nil
}
if err != nil {
return "", "", err
}
return "external", upload.SourceChannel, nil
}
func listingHandoffMode(listing model.RentalListing) string { func listingHandoffMode(listing model.RentalListing) string {
if listing.HandoffMode != "" { if listing.HandoffMode != "" {
return listing.HandoffMode return listing.HandoffMode
@@ -421,6 +440,7 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID
if order.Status != orderStatusPendingHandoff || order.HandoffStatus != handoffStatusPendingRenterConfirm { if order.Status != orderStatusPendingHandoff || order.HandoffStatus != handoffStatusPendingRenterConfirm {
return ErrOrderCannotReceive return ErrOrderCannotReceive
} }
before := orderState(order)
now := time.Now() now := time.Now()
if err := tx.Model(&model.HandoffRecord{}). if err := tx.Model(&model.HandoffRecord{}).
Where("order_id = ? AND type IN ?", order.ID, []string{"owner_handoff", "platform_handoff"}). Where("order_id = ? AND type IN ?", order.ID, []string{"owner_handoff", "platform_handoff"}).
@@ -431,6 +451,9 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID
order.HandoffStatus = handoffStatusReceived order.HandoffStatus = handoffStatusReceived
order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot) order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot)
order.RentedAt = &now order.RentedAt = &now
if err := appendOrderEvent(tx, order, "handoff", "renter_received_confirmed", processlog.ActorUser, userID, processlog.ActorUser, &order.OwnerID, "租客确认已收号,订单开始租用。", "", nil, before, nil); err != nil {
return err
}
orderID := order.ID orderID := order.ID
if isPlatformHandoffOrder(order) { if isPlatformHandoffOrder(order) {
if err := appendManagedAdminNotification(tx, order, "handoff", "租客已确认收号", "代管订单已进入使用中。"); err != nil { if err := appendManagedAdminNotification(tx, order, "handoff", "租客已确认收号", "代管订单已进入使用中。"); err != nil {
@@ -7,6 +7,7 @@ import (
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/adminnotification" "hfb_sys/backend/internal/modules/adminnotification"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -81,6 +82,7 @@ func (r *Repository) AdminPlatformHandoff(ctx context.Context, adminID uint64, o
if !canAdminPlatformHandoff(order) { if !canAdminPlatformHandoff(order) {
return ErrOrderCannotHandoff return ErrOrderCannotHandoff
} }
before := orderState(order)
beforeHandoffStatus := order.HandoffStatus beforeHandoffStatus := order.HandoffStatus
if order.ManagedAdminID == nil { if order.ManagedAdminID == nil {
order.ManagedAdminID = &adminID order.ManagedAdminID = &adminID
@@ -98,6 +100,9 @@ func (r *Repository) AdminPlatformHandoff(ctx context.Context, adminID uint64, o
now := time.Now() now := time.Now()
order.HandoffStatus = handoffStatusPendingRenterConfirm order.HandoffStatus = handoffStatusPendingRenterConfirm
order.HandoffStartedAt = &now order.HandoffStartedAt = &now
if err := appendOrderEvent(tx, order, "handoff", "platform_handoff_submitted", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, req.Content, req.Reason, nil, before, nil); err != nil {
return err
}
orderID := order.ID orderID := order.ID
content := "客服已提交交接说明,请查看交接记录,确认账号可正常登录后点击确认收号。" content := "客服已提交交接说明,请查看交接记录,确认账号可正常登录后点击确认收号。"
if beforeHandoffStatus == handoffStatusOwnerTimeout { if beforeHandoffStatus == handoffStatusOwnerTimeout {
@@ -146,6 +151,7 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u
if !canAdminPlatformCheckoutConfirm(order) { if !canAdminPlatformCheckoutConfirm(order) {
return ErrCheckoutCannotConfirm return ErrCheckoutCannotConfirm
} }
before := orderState(order)
checkout, err := lockOpenCheckout(tx, order.ID) checkout, err := lockOpenCheckout(tx, order.ID)
if err != nil { if err != nil {
return err return err
@@ -175,6 +181,9 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u
if err != nil { if err != nil {
return err return err
} }
if err := appendOrderEvent(tx, order, "checkout", "platform_checkout_confirmed", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服确认当前结账方案,订单完成。", req.Reason, checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS)); err != nil {
return err
}
return appendAuditLog(tx, adminID, "order.platform_checkout_confirm", "order", order.ID, meta, map[string]any{ return appendAuditLog(tx, adminID, "order.platform_checkout_confirm", "order", order.ID, meta, map[string]any{
"order_id": order.ID, "order_id": order.ID,
"order_no": order.OrderNo, "order_no": order.OrderNo,
@@ -207,6 +216,7 @@ func (r *Repository) AdminPlatformCheckoutCounter(ctx context.Context, adminID u
if !canAdminPlatformCheckoutConfirm(order) { if !canAdminPlatformCheckoutConfirm(order) {
return ErrCheckoutCannotCounter return ErrCheckoutCannotCounter
} }
before := orderState(order)
if order.ManagedAdminID == nil { if order.ManagedAdminID == nil {
order.ManagedAdminID = &adminID order.ManagedAdminID = &adminID
} }
@@ -259,6 +269,9 @@ func (r *Repository) AdminPlatformCheckoutCounter(ctx context.Context, adminID u
if err := tx.Create(&record).Error; err != nil { if err := tx.Create(&record).Error; err != nil {
return err return err
} }
if err := appendOrderEvent(tx, order, "checkout", "platform_checkout_countered", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "", req.Reason, checkoutEventPayload(*checkout), before, req.EvidenceURLS); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.platform_checkout_counter", "order", order.ID, meta, map[string]any{ if err := appendAuditLog(tx, adminID, "order.platform_checkout_counter", "order", order.ID, meta, map[string]any{
"order_id": order.ID, "order_id": order.ID,
"order_no": order.OrderNo, "order_no": order.OrderNo,
@@ -308,6 +321,7 @@ func (r *Repository) AdminPlatformCheckoutDispute(ctx context.Context, adminID u
if !canAdminPlatformCheckoutConfirm(order) { if !canAdminPlatformCheckoutConfirm(order) {
return ErrCheckoutCannotCounter return ErrCheckoutCannotCounter
} }
before := orderState(order)
if order.ManagedAdminID == nil { if order.ManagedAdminID == nil {
order.ManagedAdminID = &adminID order.ManagedAdminID = &adminID
} }
@@ -369,6 +383,9 @@ func (r *Repository) AdminPlatformCheckoutDispute(ctx context.Context, adminID u
if err := tx.Create(&record).Error; err != nil { if err := tx.Create(&record).Error; err != nil {
return err return err
} }
if err := appendOrderEvent(tx, order, "dispute", "platform_checkout_dispute_opened", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服发起结账争议。", req.Reason, map[string]any{"checkout_id": checkout.ID, "dispute_id": row.ID}, before, nil); err != nil {
return err
}
disputeID := row.ID disputeID := row.ID
orderID := order.ID orderID := order.ID
if err := notification.Append(tx, notification.Entry{ if err := notification.Append(tx, notification.Entry{
@@ -415,6 +432,7 @@ func (r *Repository) AdminMarkOfflineSettlement(ctx context.Context, adminID uin
if !canAdminMarkOfflineSettlement(order) { if !canAdminMarkOfflineSettlement(order) {
return ErrOfflineSettlementCannotMark return ErrOfflineSettlementCannotMark
} }
before := orderState(order)
beforeStatus := order.OfflineSettlementStatus beforeStatus := order.OfflineSettlementStatus
now := time.Now() now := time.Now()
order.OfflineSettlementStatus = offlineSettlementStatusSettled order.OfflineSettlementStatus = offlineSettlementStatusSettled
@@ -422,6 +440,9 @@ func (r *Repository) AdminMarkOfflineSettlement(ctx context.Context, adminID uin
order.OfflineSettledBy = &adminID order.OfflineSettledBy = &adminID
order.OfflineSettledAt = &now order.OfflineSettledAt = &now
order.OwnerSettledAt = &now order.OwnerSettledAt = &now
if err := appendOrderEvent(tx, order, "settlement", "offline_settlement_confirmed", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.OwnerID, "确认已完成线下结算。", req.Remark, map[string]any{"offline_settlement_amount_cent": order.OfflineSettlementAmountCent}, before, nil); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "order.offline_settlement", "order", order.ID, meta, map[string]any{ if err := appendAuditLog(tx, adminID, "order.offline_settlement", "order", order.ID, meta, map[string]any{
"order_id": order.ID, "order_id": order.ID,
"order_no": order.OrderNo, "order_no": order.OrderNo,
@@ -107,6 +107,8 @@ func (row orderRow) toAdminDTO() OrderDTO {
GrowthPointsAwarded: row.GrowthPointsAwarded, GrowthPointsAwarded: row.GrowthPointsAwarded,
GrowthPointsAwardedAt: row.GrowthPointsAwardedAt, GrowthPointsAwardedAt: row.GrowthPointsAwardedAt,
AccountSnapshot: row.AccountSnapshot, AccountSnapshot: row.AccountSnapshot,
AccountSource: effectiveAccountSource(row.RentalOrder),
SourceChannel: row.SourceChannel,
Status: row.Status, Status: row.Status,
HandoffStatus: row.HandoffStatus, HandoffStatus: row.HandoffStatus,
HandoffMode: effectiveHandoffMode(row.RentalOrder), HandoffMode: effectiveHandoffMode(row.RentalOrder),
@@ -131,6 +133,13 @@ func (row orderRow) toAdminDTO() OrderDTO {
} }
} }
func effectiveAccountSource(order model.RentalOrder) string {
if order.AccountSource != "" {
return order.AccountSource
}
return "internal"
}
func (row orderRow) toDTOForUser(userID uint64) OrderDTO { func (row orderRow) toDTOForUser(userID uint64) OrderDTO {
dto := row.toAdminDTO() dto := row.toAdminDTO()
dto.AdminActions = nil dto.AdminActions = nil
@@ -146,6 +155,9 @@ func (row orderRow) toDTOForUser(userID uint64) OrderDTO {
dto.OfflineSettlementRemark = "" dto.OfflineSettlementRemark = ""
dto.OfflineSettledBy = nil dto.OfflineSettledBy = nil
dto.OfflineSettledAt = nil dto.OfflineSettledAt = nil
// 外部来源渠道属于后台经营信息,仅在管理员订单详情展示。
dto.AccountSource = ""
dto.SourceChannel = ""
applyOrderPriceView(&dto, row.RentalOrder, userID) applyOrderPriceView(&dto, row.RentalOrder, userID)
return dto return dto
} }
@@ -0,0 +1,64 @@
package order
import (
"context"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/processlog"
"gorm.io/gorm"
)
const processBusinessOrder = "order"
func orderState(order model.RentalOrder) map[string]any {
return map[string]any{
"order_status": order.Status, "handoff_status": order.HandoffStatus,
"settlement_status": order.SettlementStatus,
"offline_settlement_status": effectiveOfflineSettlementStatus(order),
}
}
func checkoutEventPayload(checkout model.OrderCheckout) map[string]any {
return map[string]any{
"checkout_id": checkout.ID, "round": checkout.RoundCount, "turn": normalizeCheckoutTurn(&checkout),
"proposed_by": checkout.ProposedBy, "rent_amount_cent": checkout.RentAmountCent,
"owner_rent_amount_cent": checkout.OwnerRentAmountCent, "platform_fee_cent": checkout.PlatformFeeCent,
"deposit_amount_cent": checkout.DepositAmountCent, "consumable_amount_cent": checkout.ConsumableAmountCent,
"coin_consumed_m": checkout.CoinConsumedM, "deposit_deduct_amount_cent": checkout.DepositDeductAmountCent,
"renter_refund_amount_cent": checkout.RenterRefundAmountCent, "owner_income_amount_cent": checkout.OwnerIncomeAmountCent,
"shortfall_cent": checkout.ShortfallCent, "overshoot_amount_cent": checkout.OvershootAmountCent,
}
}
func appendOrderEvent(tx *gorm.DB, order model.RentalOrder, stage, action, actorType string, actorID uint64, targetType string, targetID *uint64, content, reason string, payload, before map[string]any, attachments []string) error {
return processlog.Append(tx, processlog.Entry{
BusinessType: processBusinessOrder, BusinessID: order.ID, Stage: stage, Action: action,
ActorType: actorType, ActorID: actorID, TargetType: targetType, TargetID: targetID,
Content: content, Reason: reason, Payload: payload, Attachments: attachments,
StateBefore: before, StateAfter: orderState(order),
})
}
func (r *Repository) ProcessEventsAdmin(ctx context.Context, orderID uint64) ([]ProcessEventDTO, error) {
var order model.RentalOrder
if err := r.db.WithContext(ctx).First(&order, orderID).Error; err != nil {
return nil, err
}
rows, err := processlog.List(r.db.WithContext(ctx), processBusinessOrder, orderID)
if err != nil {
return nil, err
}
items := make([]ProcessEventDTO, 0, len(rows))
for _, row := range rows {
items = append(items, ProcessEventDTO{
ID: row.ID, BusinessType: row.BusinessType, BusinessID: row.BusinessID, Stage: row.Stage,
Action: row.Action, ActorType: row.ActorType, ActorID: row.ActorID, ActorName: row.ActorName,
TargetType: row.TargetType, TargetID: row.TargetID, TargetName: row.TargetName,
Content: row.Content, Reason: row.Reason, Payload: row.Payload,
AttachmentURLs: decodeStringList(row.AttachmentURLs), StateBefore: row.StateBefore,
StateAfter: row.StateAfter, CreatedAt: row.CreatedAt,
})
}
return items, nil
}
@@ -184,6 +184,13 @@ func (s *Service) HandoffRecordsAdmin(ctx context.Context, orderID uint64) ([]Ha
return s.repo.HandoffRecordsAdmin(ctx, orderID) return s.repo.HandoffRecordsAdmin(ctx, orderID)
} }
func (s *Service) ProcessEventsAdmin(ctx context.Context, orderID uint64) ([]ProcessEventDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ProcessEventsAdmin(ctx, orderID)
}
func (s *Service) AdminClose(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { func (s *Service) AdminClose(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
if s.repo == nil { if s.repo == nil {
return ErrDependencyUnavailable return ErrDependencyUnavailable
@@ -150,6 +150,19 @@ func (h *Handler) ListFinancialAdjustments(c *gin.Context) {
response.OK(c, items) response.OK(c, items)
} }
func (h *Handler) ProcessEvents(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
items, err := h.service.ProcessEvents(c.Request.Context(), id)
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
// SettleFinancialAdjustment 确认代管提号的补款或追回调整已线下处理。 // SettleFinancialAdjustment 确认代管提号的补款或追回调整已线下处理。
func (h *Handler) SettleFinancialAdjustment(c *gin.Context) { func (h *Handler) SettleFinancialAdjustment(c *gin.Context) {
adminID, ok := currentAdminID(c) adminID, ok := currentAdminID(c)
@@ -0,0 +1,76 @@
package pickup
import (
"context"
"encoding/json"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/processlog"
"gorm.io/datatypes"
"gorm.io/gorm"
)
const processBusinessPickup = "pickup"
type ProcessEventDTO struct {
ID uint64 `json:"id"`
Stage string `json:"stage"`
Action string `json:"action"`
ActorType string `json:"actor_type"`
ActorID uint64 `json:"actor_id"`
ActorName string `json:"actor_name"`
TargetType string `json:"target_type"`
TargetID *uint64 `json:"target_id,omitempty"`
TargetName string `json:"target_name"`
Content string `json:"content"`
Reason string `json:"reason"`
Payload datatypes.JSON `json:"payload"`
AttachmentURLs []string `json:"attachment_urls"`
StateBefore datatypes.JSON `json:"state_before"`
StateAfter datatypes.JSON `json:"state_after"`
CreatedAt string `json:"created_at"`
}
func pickupState(row model.AdminPickup) map[string]any {
return map[string]any{
"pickup_status": row.Status, "offline_settlement_status": row.OfflineSettlementStatus,
"settlement_mode": row.SettlementMode,
}
}
func appendPickupEvent(tx *gorm.DB, pickup model.AdminPickup, action string, adminID uint64, content, reason string, payload, before map[string]any) error {
return processlog.Append(tx, processlog.Entry{
BusinessType: processBusinessPickup, BusinessID: pickup.ID, Stage: "pickup", Action: action,
ActorType: processlog.ActorAdmin, ActorID: adminID, TargetType: processlog.ActorUser,
TargetID: &pickup.OwnerID, Content: content, Reason: reason, Payload: payload,
StateBefore: before, StateAfter: pickupState(pickup),
})
}
func (r *Repository) ProcessEvents(ctx context.Context, pickupID uint64) ([]ProcessEventDTO, error) {
var pickup model.AdminPickup
if err := r.db.WithContext(ctx).First(&pickup, pickupID).Error; err != nil {
return nil, err
}
rows, err := processlog.List(r.db.WithContext(ctx), processBusinessPickup, pickupID)
if err != nil {
return nil, err
}
items := make([]ProcessEventDTO, 0, len(rows))
for _, row := range rows {
items = append(items, ProcessEventDTO{
ID: row.ID, Stage: row.Stage, Action: row.Action, ActorType: row.ActorType, ActorID: row.ActorID,
ActorName: row.ActorName, TargetType: row.TargetType, TargetID: row.TargetID, TargetName: row.TargetName,
Content: row.Content, Reason: row.Reason, Payload: row.Payload, AttachmentURLs: decodeStringList(row.AttachmentURLs),
StateBefore: row.StateBefore, StateAfter: row.StateAfter, CreatedAt: row.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
return items, nil
}
func decodeStringList(raw datatypes.JSON) []string {
items := make([]string, 0)
_ = json.Unmarshal(raw, &items)
return items
}
@@ -105,6 +105,13 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint
if err := tx.Create(&pickup).Error; err != nil { if err := tx.Create(&pickup).Error; err != nil {
return err return err
} }
if err := appendPickupEvent(tx, pickup, "pickup_created", adminID, "管理员创建线下提号。", pickup.Remark, map[string]any{
"listing_price_cent": pickup.ListingPriceCent, "profit_amount_cent": pickup.ProfitAmountCent,
"account_source": pickup.AccountSource, "source_channel": pickup.SourceChannel,
"settlement_mode": pickup.SettlementMode,
}, map[string]any{}); err != nil {
return err
}
createdID = pickup.ID createdID = pickup.ID
// 锁定商品:status=rented,从「已上架」列表移除,进入「已锁定」 // 锁定商品:status=rented,从「已上架」列表移除,进入「已锁定」
@@ -188,6 +195,7 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete
if pickup.Status != StatusPickingUp { if pickup.Status != StatusPickingUp {
return ErrPickupNotPickingUp return ErrPickupNotPickingUp
} }
before := pickupState(pickup)
var listing model.RentalListing var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, pickup.ListingID).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, pickup.ListingID).Error; err != nil {
@@ -273,6 +281,12 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete
return err return err
} }
} }
if err := appendPickupEvent(tx, pickup, "pickup_completed", adminID, "管理员完成提号。", pickup.CompleteRemark, map[string]any{
"settle_amount_cent": pickup.SettleAmountCent, "profit_amount_cent": pickup.ProfitAmountCent,
"settlement_mode": pickup.SettlementMode, "offline_settlement_status": pickup.OfflineSettlementStatus,
}, before); err != nil {
return err
}
if err := auditlog.Append(tx, auditlog.Entry{ if err := auditlog.Append(tx, auditlog.Entry{
ActorType: "admin", ActorType: "admin",
@@ -316,6 +330,7 @@ func (r *Repository) MarkOfflineSettlement(ctx context.Context, pickupID uint64,
pickup.OfflineSettlementStatus != OfflineSettlementStatusPending || pickup.SettleAmountCent <= 0 { pickup.OfflineSettlementStatus != OfflineSettlementStatusPending || pickup.SettleAmountCent <= 0 {
return ErrOfflineSettlementCannotMark return ErrOfflineSettlementCannotMark
} }
before := pickupState(pickup)
pendingTotals, err := pendingPickupFinancialAdjustmentTotals(tx, pickup.ID) pendingTotals, err := pendingPickupFinancialAdjustmentTotals(tx, pickup.ID)
if err != nil { if err != nil {
return err return err
@@ -345,6 +360,11 @@ func (r *Repository) MarkOfflineSettlement(ctx context.Context, pickupID uint64,
return err return err
} }
} }
if err := appendPickupEvent(tx, pickup, "pickup_offline_settlement_confirmed", adminID, "确认已完成提号线下结算。", pickup.OfflineSettlementRemark, map[string]any{
"settle_amount_cent": effectiveSettleAmountCent, "settled_adjustment_count": pendingTotals.Count,
}, before); err != nil {
return err
}
bid := pickup.ID bid := pickup.ID
return auditlog.Append(tx, auditlog.Entry{ return auditlog.Append(tx, auditlog.Entry{
@@ -386,12 +406,18 @@ func (r *Repository) UpdateProfit(ctx context.Context, pickupID uint64, req Upda
if pickup.Status != StatusPickingUp { if pickup.Status != StatusPickingUp {
return ErrPickupNotPickingUp return ErrPickupNotPickingUp
} }
before := pickupState(pickup)
oldProfitAmountCent := pickup.ProfitAmountCent oldProfitAmountCent := pickup.ProfitAmountCent
pickup.ProfitAmountCent = req.ProfitAmountCent pickup.ProfitAmountCent = req.ProfitAmountCent
if err := tx.Model(&pickup).Update("profit_amount_cent", pickup.ProfitAmountCent).Error; err != nil { if err := tx.Model(&pickup).Update("profit_amount_cent", pickup.ProfitAmountCent).Error; err != nil {
return err return err
} }
if err := appendPickupEvent(tx, pickup, "pickup_profit_updated", adminID, "修改提号利润。", strings.TrimSpace(req.Reason), map[string]any{
"old_profit_amount_cent": oldProfitAmountCent, "profit_amount_cent": pickup.ProfitAmountCent,
}, before); err != nil {
return err
}
bid := pickup.ID bid := pickup.ID
return auditlog.Append(tx, auditlog.Entry{ return auditlog.Append(tx, auditlog.Entry{
@@ -431,6 +457,7 @@ func (r *Repository) CreateFinancialAdjustment(ctx context.Context, pickupID uin
if pickup.Status != StatusCompleted { if pickup.Status != StatusCompleted {
return ErrFinancialAdjustmentInvalid return ErrFinancialAdjustmentInvalid
} }
before := pickupState(pickup)
totals, err := pickupFinancialAdjustmentTotals(tx, pickup.ID) totals, err := pickupFinancialAdjustmentTotals(tx, pickup.ID)
if err != nil { if err != nil {
@@ -471,6 +498,13 @@ func (r *Repository) CreateFinancialAdjustment(ctx context.Context, pickupID uin
if err := tx.Create(&adjustment).Error; err != nil { if err := tx.Create(&adjustment).Error; err != nil {
return err return err
} }
if err := appendPickupEvent(tx, pickup, "pickup_financial_adjusted", adminID, "创建提号财务调整。", adjustment.Reason, map[string]any{
"adjustment_id": adjustment.ID, "profit_delta_cent": adjustment.ProfitDeltaCent,
"settle_delta_cent": adjustment.SettleDeltaCent, "settlement_mode": adjustment.SettlementMode,
"adjustment_status": adjustment.Status,
}, before); err != nil {
return err
}
// 站内钱包的补款在创建调整时立即到账;扣款统一转待追回,避免余额被扣成负数。 // 站内钱包的补款在创建调整时立即到账;扣款统一转待追回,避免余额被扣成负数。
if pickup.SettlementMode == SettlementModeOwnerWallet && settleDeltaCent > 0 { if pickup.SettlementMode == SettlementModeOwnerWallet && settleDeltaCent > 0 {
@@ -559,6 +593,17 @@ func (r *Repository) SettleFinancialAdjustment(ctx context.Context, adjustmentID
if err := tx.Save(&adjustment).Error; err != nil { if err := tx.Save(&adjustment).Error; err != nil {
return err return err
} }
var pickup model.AdminPickup
if err := tx.First(&pickup, adjustment.PickupID).Error; err != nil {
return err
}
if err := appendPickupEvent(tx, pickup, "pickup_adjustment_settled", adminID, "确认提号财务调整已处理。", adjustment.SettlementRemark, map[string]any{
"adjustment_id": adjustment.ID, "profit_delta_cent": adjustment.ProfitDeltaCent,
"settle_delta_cent": adjustment.SettleDeltaCent, "before_status": beforeStatus,
"adjustment_status": adjustment.Status,
}, pickupState(pickup)); err != nil {
return err
}
bid := adjustment.ID bid := adjustment.ID
return auditlog.Append(tx, auditlog.Entry{ return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin", ActorType: "admin",
@@ -595,6 +640,7 @@ func (r *Repository) Cancel(ctx context.Context, pickupID uint64, reason string,
if pickup.Status != StatusPickingUp { if pickup.Status != StatusPickingUp {
return ErrPickupNotPickingUp return ErrPickupNotPickingUp
} }
before := pickupState(pickup)
var listing model.RentalListing var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, pickup.ListingID).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, pickup.ListingID).Error; err != nil {
return err return err
@@ -609,6 +655,9 @@ func (r *Repository) Cancel(ctx context.Context, pickupID uint64, reason string,
if err := tx.Save(&pickup).Error; err != nil { if err := tx.Save(&pickup).Error; err != nil {
return err return err
} }
if err := appendPickupEvent(tx, pickup, "pickup_cancelled", adminID, "管理员取消提号。", reason, nil, before); err != nil {
return err
}
// 取消提号:恢复上架,重新出现在商品管理「已上架」 // 取消提号:恢复上架,重新出现在商品管理「已上架」
fromStatus := listing.Status fromStatus := listing.Status
listing.InTransaction = false listing.InTransaction = false
@@ -607,6 +607,7 @@ func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) {
} }
if err := db.AutoMigrate( if err := db.AutoMigrate(
&model.User{}, &model.User{},
&model.AdminUser{},
&model.GameAccount{}, &model.GameAccount{},
&model.RentalListing{}, &model.RentalListing{},
&model.ListingUpload{}, &model.ListingUpload{},
@@ -615,6 +616,7 @@ func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) {
&model.WalletAccount{}, &model.WalletAccount{},
&model.WalletLedger{}, &model.WalletLedger{},
&model.AuditLog{}, &model.AuditLog{},
&model.ProcessEvent{},
&model.Notification{}, &model.Notification{},
&model.ListingStatusEvent{}, &model.ListingStatusEvent{},
); err != nil { ); err != nil {
@@ -87,6 +87,13 @@ func (s *Service) ListFinancialAdjustments(ctx context.Context, pickupID uint64)
return s.repo.ListFinancialAdjustments(ctx, pickupID) return s.repo.ListFinancialAdjustments(ctx, pickupID)
} }
func (s *Service) ProcessEvents(ctx context.Context, pickupID uint64) ([]ProcessEventDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ProcessEvents(ctx, pickupID)
}
func (s *Service) SettleFinancialAdjustment(ctx context.Context, adjustmentID uint64, req FinancialAdjustmentSettlementRequest, adminID uint64, meta auditlog.Meta) error { func (s *Service) SettleFinancialAdjustment(ctx context.Context, adjustmentID uint64, req FinancialAdjustmentSettlementRequest, adminID uint64, meta auditlog.Meta) error {
if s.repo == nil { if s.repo == nil {
return ErrDependencyUnavailable return ErrDependencyUnavailable
+128
View File
@@ -0,0 +1,128 @@
// Package processlog 提供交易过程的追加式留痕能力。
package processlog
import (
"encoding/json"
"strconv"
"strings"
"hfb_sys/backend/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
)
const (
ActorUser = "user"
ActorAdmin = "admin"
ActorSystem = "system"
)
type Entry struct {
BusinessType string
BusinessID uint64
Stage string
Action string
ActorType string
ActorID uint64
TargetType string
TargetID *uint64
Content string
Reason string
Payload map[string]any
Attachments []string
StateBefore map[string]any
StateAfter map[string]any
}
func Append(tx *gorm.DB, entry Entry) error {
actorName, err := displayName(tx, entry.ActorType, entry.ActorID)
if err != nil {
return err
}
targetName := ""
if entry.TargetID != nil && *entry.TargetID > 0 {
targetName, err = displayName(tx, entry.TargetType, *entry.TargetID)
if err != nil {
return err
}
}
return tx.Create(&model.ProcessEvent{
BusinessType: entry.BusinessType,
BusinessID: entry.BusinessID,
Stage: entry.Stage,
Action: entry.Action,
ActorType: entry.ActorType,
ActorID: entry.ActorID,
ActorName: actorName,
TargetType: entry.TargetType,
TargetID: entry.TargetID,
TargetName: targetName,
Content: strings.TrimSpace(entry.Content),
Reason: strings.TrimSpace(entry.Reason),
Payload: jsonValue(entry.Payload),
AttachmentURLs: jsonValue(entry.Attachments),
StateBefore: jsonValue(entry.StateBefore),
StateAfter: jsonValue(entry.StateAfter),
}).Error
}
func List(tx *gorm.DB, businessType string, businessID uint64) ([]model.ProcessEvent, error) {
var rows []model.ProcessEvent
err := tx.Where("business_type = ? AND business_id = ?", businessType, businessID).
Order("id ASC").Find(&rows).Error
return rows, err
}
func displayName(tx *gorm.DB, actorType string, id uint64) (string, error) {
if id == 0 || actorType == ActorSystem {
return "系统", nil
}
switch actorType {
case ActorAdmin:
var row model.AdminUser
if err := tx.Select("id", "username", "nickname").First(&row, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return "客服 ID " + formatID(id), nil
}
return "", err
}
if strings.TrimSpace(row.Nickname) != "" {
return row.Nickname + "(客服)", nil
}
if strings.TrimSpace(row.Username) != "" {
return row.Username + "(客服)", nil
}
return "客服 ID " + formatID(id), nil
default:
var row model.User
if err := tx.Select("id", "nickname", "phone").First(&row, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return "用户 ID " + formatID(id), nil
}
return "", err
}
if strings.TrimSpace(row.Nickname) != "" {
return row.Nickname + "(用户)", nil
}
if strings.TrimSpace(row.Phone) != "" {
return row.Phone + "(用户)", nil
}
return "用户 ID " + formatID(id), nil
}
}
func jsonValue(value any) datatypes.JSON {
if value == nil {
return datatypes.JSON([]byte("{}"))
}
raw, err := json.Marshal(value)
if err != nil || len(raw) == 0 {
return datatypes.JSON([]byte("{}"))
}
return datatypes.JSON(raw)
}
func formatID(id uint64) string {
return strconv.FormatUint(id, 10)
}
+2
View File
@@ -603,6 +603,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing) adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing)
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail) adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords) adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords)
adminRoutes.GET("/orders/:id/process-events", requirePerm("order:view"), orderHandler.AdminProcessEvents)
adminRoutes.POST("/orders/:id/close", requirePerm("order:close"), orderHandler.AdminClose) adminRoutes.POST("/orders/:id/close", requirePerm("order:close"), orderHandler.AdminClose)
adminRoutes.POST("/orders/:id/seal", requirePerm("order:close"), orderHandler.AdminSeal) adminRoutes.POST("/orders/:id/seal", requirePerm("order:close"), orderHandler.AdminSeal)
adminRoutes.POST("/orders/:id/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal) adminRoutes.POST("/orders/:id/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal)
@@ -629,6 +630,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.GET("/pickups/shop-options", requirePerm("order:pickup"), pickupHandler.ShopOptions) adminRoutes.GET("/pickups/shop-options", requirePerm("order:pickup"), pickupHandler.ShopOptions)
adminRoutes.GET("/pickups/available-listings", requirePerm("order:pickup"), pickupHandler.AvailableListings) adminRoutes.GET("/pickups/available-listings", requirePerm("order:pickup"), pickupHandler.AvailableListings)
adminRoutes.GET("/pickups/:id/financial-adjustments", requirePerm("order:pickup"), pickupHandler.ListFinancialAdjustments) adminRoutes.GET("/pickups/:id/financial-adjustments", requirePerm("order:pickup"), pickupHandler.ListFinancialAdjustments)
adminRoutes.GET("/pickups/:id/process-events", requirePerm("order:pickup"), pickupHandler.ProcessEvents)
adminRoutes.POST("/pickups/:id/financial-adjustments", requirePerm("order:pickup"), pickupHandler.CreateFinancialAdjustment) adminRoutes.POST("/pickups/:id/financial-adjustments", requirePerm("order:pickup"), pickupHandler.CreateFinancialAdjustment)
adminRoutes.GET("/pickups/:id", requirePerm("order:pickup"), pickupHandler.Detail) adminRoutes.GET("/pickups/:id", requirePerm("order:pickup"), pickupHandler.Detail)
adminRoutes.POST("/pickups/:id/complete", requirePerm("order:pickup"), pickupHandler.Complete) adminRoutes.POST("/pickups/:id/complete", requirePerm("order:pickup"), pickupHandler.Complete)
@@ -0,0 +1,37 @@
-- +goose Up
CREATE TABLE process_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
business_type VARCHAR(32) NOT NULL,
business_id BIGINT UNSIGNED NOT NULL,
stage VARCHAR(32) NOT NULL DEFAULT '',
action VARCHAR(64) NOT NULL,
actor_type VARCHAR(16) NOT NULL,
actor_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
actor_name VARCHAR(128) NOT NULL DEFAULT '',
target_type VARCHAR(16) NOT NULL DEFAULT '',
target_id BIGINT UNSIGNED NULL,
target_name VARCHAR(128) NOT NULL DEFAULT '',
content TEXT NOT NULL,
reason TEXT NOT NULL,
payload JSON NOT NULL,
attachment_urls JSON NOT NULL,
state_before JSON NOT NULL,
state_after JSON NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_process_event_business (business_type, business_id, id),
KEY idx_process_event_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单与提号不可变过程留痕';
ALTER TABLE rental_orders
ADD COLUMN account_source VARCHAR(32) NOT NULL DEFAULT 'internal' COMMENT '订单创建时账号来源快照' AFTER account_snapshot,
ADD COLUMN source_channel VARCHAR(32) NOT NULL DEFAULT '' COMMENT '订单创建时外部来源渠道快照' AFTER account_source;
-- +goose Down
ALTER TABLE rental_orders
DROP COLUMN source_channel,
DROP COLUMN account_source;
DROP TABLE IF EXISTS process_events;
@@ -63,6 +63,25 @@ export interface AdminPickupFinancialAdjustment {
created_at: string created_at: string
} }
export interface AdminPickupProcessEvent {
id: number
stage: string
action: string
actor_type: string
actor_id: number
actor_name: string
target_type: string
target_id?: number
target_name: string
content: string
reason: string
payload: Record<string, unknown>
attachment_urls: string[]
state_before: Record<string, unknown>
state_after: Record<string, unknown>
created_at: string
}
export interface AvailableListing { export interface AvailableListing {
id: number id: number
listing_no: string listing_no: string
@@ -226,6 +245,13 @@ export async function fetchAdminPickupFinancialAdjustments(id: string | number)
return Array.isArray(data.data) ? data.data : [] return Array.isArray(data.data) ? data.data : []
} }
export async function fetchAdminPickupProcessEvents(id: string | number) {
const { data } = await apiClient.get<ApiResponse<{ items: AdminPickupProcessEvent[] }>>(
`/admin/pickups/${id}/process-events`
)
return Array.isArray(data.data?.items) ? data.data.items : []
}
export async function settleAdminPickupFinancialAdjustment( export async function settleAdminPickupFinancialAdjustment(
id: number, id: number,
req: AdminPickupFinancialAdjustmentSettlementRequest = {} req: AdminPickupFinancialAdjustmentSettlementRequest = {}
@@ -0,0 +1,225 @@
<script setup lang="ts">
import { formatCentWithSymbol } from '@/shared/utils/money'
import { formatDateTime } from '@/shared/utils/time'
export interface ProcessTimelineEvent {
id: number
stage: string
action: string
actor_type: string
actor_id: number
actor_name: string
target_type?: string
target_id?: number
target_name?: string
content?: string
reason?: string
payload?: Record<string, unknown>
attachment_urls?: string[]
state_before?: Record<string, unknown>
state_after?: Record<string, unknown>
created_at: string
}
const props = defineProps<{ events: ProcessTimelineEvent[]; emptyText?: string }>()
const actionLabels: Record<string, string> = {
owner_handoff_submitted: '号主提交交接', platform_handoff_submitted: '客服代交接',
admin_force_handoff: '客服确认交接', renter_received_confirmed: '租客确认收号',
checkout_submitted: '租客发起结账', checkout_countered: '修改结账方案',
checkout_confirmed: '号主确认结账', checkout_accepted: '租客接受结账方案',
platform_checkout_countered: '客服修改结账方案', platform_checkout_confirmed: '客服确认结账',
platform_checkout_dispute_opened: '客服发起结账争议', offline_settlement_confirmed: '确认线下结算',
deposit_held: '暂扣押金', deposit_released: '归还暂扣押金', pickup_created: '创建提号',
pickup_completed: '完成提号', pickup_profit_updated: '修改提号利润',
pickup_financial_adjusted: '创建财务调整', pickup_adjustment_settled: '确认财务调整',
pickup_offline_settlement_confirmed: '确认提号线下结算', pickup_cancelled: '取消提号',
}
const stageLabels: Record<string, string> = {
handoff: '交接', checkout: '结账', settlement: '结算', dispute: '争议', pickup: '提号',
}
const payloadLabels: Record<string, string> = {
checkout_id: '结账单', round: '协商轮次', turn: '等待确认方', proposed_by: '方案提出人',
rent_amount_cent: '实际结算租金', owner_rent_amount_cent: '号主租金', platform_fee_cent: '平台费用',
deposit_amount_cent: '押金', consumable_amount_cent: '消耗品金额', coin_consumed_m: '哈夫币消耗',
deposit_deduct_amount_cent: '押金赔付扣除', renter_refund_amount_cent: '退还租客',
owner_income_amount_cent: '号主最终收入', shortfall_cent: '押金不足差额', overshoot_amount_cent: '打超金额',
offline_settlement_amount_cent: '线下结算金额', deposit_hold_amount_cent: '暂扣金额',
profit_amount_cent: '利润金额', settle_amount_cent: '结算给号主', profit_delta_cent: '利润调整',
settle_delta_cent: '结算调整', settlement_mode: '结算方式', account_source: '账号来源', source_channel: '来源渠道',
}
const primaryPayloadKeys = new Set([
'round', 'turn', 'rent_amount_cent', 'renter_refund_amount_cent', 'owner_income_amount_cent',
'offline_settlement_amount_cent', 'profit_amount_cent', 'settle_amount_cent',
])
const stateLabels: Record<string, Record<string, string>> = {
order_status: {
pending_payment: '待支付', pending_handoff: '待交接', renting: '租用中', overdue: '已逾期',
pending_checkout_confirm: '待号主确认结账', pending_checkout_accept: '待租客确认结账',
completed: '已完成', cancelled: '已取消', closed: '已关闭', abnormal: '异常',
},
handoff_status: {
none: '未开始', pending_owner: '待号主交接', pending_renter_confirm: '待租客确认收号',
received: '租客已收号', pending_owner_checkout: '待号主确认结账',
pending_renter_checkout: '待租客确认结账', returned: '已归还',
},
settlement_status: { unsettled: '未结算', pending: '结算待确认', settled: '已结算', arbitrated: '仲裁结算' },
offline_settlement_status: { none: '无需线下结算', pending: '待线下结算', settled: '已线下结算' },
pickup_status: { pending: '待处理', processing: '处理中', completed: '已完成', cancelled: '已取消' },
}
const stateFieldLabels: Record<string, string> = {
order_status: '订单', handoff_status: '交接', settlement_status: '结算',
offline_settlement_status: '线下结算', pickup_status: '提号',
}
function actionLabel(action: string) { return actionLabels[action] || action }
function stageLabel(stage: string) { return stageLabels[stage] || '流程' }
function actorLabel(item: ProcessTimelineEvent) {
if (item.actor_name) return item.actor_name
if (item.actor_type === 'system') return '系统'
return `${item.actor_type === 'admin' ? '客服' : '用户'} ID ${item.actor_id}`
}
function actorRole(item: ProcessTimelineEvent) {
if (item.actor_type === 'admin') return '客服操作'
if (item.actor_type === 'system') return '系统处理'
return '用户操作'
}
function payloadRows(payload?: Record<string, unknown>) {
if (!payload || typeof payload !== 'object') return []
return Object.entries(payload).filter(([, value]) => value !== null && value !== undefined && value !== '')
}
function primaryPayloadRows(item: ProcessTimelineEvent) {
return payloadRows(item.payload).filter(([key]) => primaryPayloadKeys.has(key))
}
function detailPayloadRows(item: ProcessTimelineEvent) {
return payloadRows(item.payload).filter(([key]) => !primaryPayloadKeys.has(key))
}
function payloadLabel(key: string) { return payloadLabels[key] || key }
function payloadValue(key: string, value: unknown) {
if (key.endsWith('_cent')) return formatCentWithSymbol(Number(value || 0))
if (key === 'turn') return value === 'owner' ? '号主' : value === 'renter' ? '租客' : String(value)
if (key === 'account_source') return value === 'external' ? '外部上传' : '站内上传'
if (key === 'settlement_mode') return value === 'platform_managed' ? '平台线下结算' : '号主钱包结算'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
function stateValue(key: string, value: unknown) {
const raw = String(value || '')
return stateLabels[key]?.[raw] || raw || '-'
}
function stateChanges(item: ProcessTimelineEvent) {
const before = item.state_before || {}
const after = item.state_after || {}
return Object.keys(stateFieldLabels)
.filter(key => before[key] !== undefined && before[key] !== after[key])
.map(key => ({ label: stateFieldLabels[key], before: stateValue(key, before[key]), after: stateValue(key, after[key]) }))
}
function hasMore(item: ProcessTimelineEvent) {
return detailPayloadRows(item).length > 0 || stateChanges(item).length > 0
}
</script>
<template>
<el-empty v-if="props.events.length === 0" :description="props.emptyText || '暂无过程记录'" />
<div v-else class="process-timeline">
<article v-for="item in props.events" :key="item.id" class="process-item">
<span class="process-dot"></span>
<header class="process-heading">
<div class="heading-main">
<span class="stage-badge">{{ stageLabel(item.stage) }}</span>
<strong>{{ actionLabel(item.action) }}</strong>
</div>
<time>{{ formatDateTime(item.created_at) }}</time>
</header>
<div class="process-people">
<span><b>{{ actorRole(item) }}</b>{{ actorLabel(item) }}</span>
<span v-if="item.target_name" class="target-text">通知/关联{{ item.target_name }}</span>
</div>
<p v-if="item.content" class="process-content">{{ item.content }}</p>
<p v-if="item.reason" class="process-reason"><b>备注/原因</b>{{ item.reason }}</p>
<div v-if="primaryPayloadRows(item).length" class="process-summary">
<div v-for="[key, value] in primaryPayloadRows(item)" :key="key">
<span>{{ payloadLabel(key) }}</span>
<strong>{{ payloadValue(key, value) }}</strong>
</div>
</div>
<details v-if="hasMore(item)" class="process-details">
<summary>查看完整明细与状态变化</summary>
<div v-if="detailPayloadRows(item).length" class="process-payload">
<div v-for="[key, value] in detailPayloadRows(item)" :key="key">
<span>{{ payloadLabel(key) }}</span>
<strong>{{ payloadValue(key, value) }}</strong>
</div>
</div>
<div v-if="stateChanges(item).length" class="process-state">
<b>状态变化</b>
<span v-for="change in stateChanges(item)" :key="change.label">
{{ change.label }}{{ change.before }} <i></i> {{ change.after }}
</span>
</div>
</details>
<div v-if="item.attachment_urls?.length" class="process-attachments">
<el-image
v-for="url in item.attachment_urls"
:key="url"
:src="url"
:preview-src-list="item.attachment_urls"
preview-teleported
fit="cover"
/>
</div>
</article>
</div>
</template>
<style scoped>
.process-timeline { display: grid; gap: 12px; padding-left: 4px; }
.process-item { position: relative; padding: 14px 16px 14px 22px; border: 1px solid #e5eaf2; border-radius: 10px; background: #fff; box-shadow: 0 1px 2px rgb(15 23 42 / 2%); }
.process-item::before { content: ''; position: absolute; top: -13px; bottom: calc(100% - 1px); left: -1px; width: 1px; background: #dce5f0; }
.process-item:first-child::before { display: none; }
.process-dot { position: absolute; left: -5px; top: 21px; width: 9px; height: 9px; border: 2px solid #fff; border-radius: 50%; background: #ff6a00; box-shadow: 0 0 0 1px #f5a561; }
.process-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.heading-main { display: flex; align-items: center; gap: 8px; min-width: 0; }
.heading-main strong { color: #182232; font-size: 15px; }
.stage-badge { flex: none; padding: 2px 7px; border-radius: 4px; background: #fff3e8; color: #d85d00; font-size: 12px; font-weight: 600; }
.process-heading time { flex: none; color: #94a3b8; font-size: 12px; white-space: nowrap; }
.process-people { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 8px; color: #64748b; font-size: 12px; }
.process-people b { margin-right: 6px; color: #475569; font-weight: 600; }
.target-text { color: #718096; }
.process-content, .process-reason { margin: 10px 0 0; color: #334155; line-height: 1.65; white-space: pre-wrap; }
.process-reason { padding: 8px 10px; border-radius: 6px; background: #fff9ed; color: #9a5b13; }
.process-reason b { margin-right: 8px; }
.process-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 11px; }
.process-summary div { display: flex; align-items: baseline; gap: 7px; padding: 7px 10px; border: 1px solid #e6edf5; border-radius: 6px; background: #f8fafc; }
.process-summary span { color: #64748b; font-size: 12px; }
.process-summary strong { color: #1e293b; font-size: 13px; }
.process-details { margin-top: 10px; }
.process-details summary { width: fit-content; color: #477fc1; font-size: 12px; cursor: pointer; user-select: none; }
.process-details[open] summary { margin-bottom: 9px; }
.process-payload { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 7px 12px; padding: 10px; border-radius: 7px; background: #f4f7fb; }
.process-payload div { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; }
.process-payload span { color: #64748b; }
.process-payload strong { color: #1e293b; text-align: right; word-break: break-word; }
.process-state { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 9px; color: #64748b; font-size: 12px; }
.process-state b { color: #475569; }
.process-state span { padding-left: 10px; border-left: 1px solid #dbe4ee; }
.process-state i { color: #94a3b8; font-style: normal; }
.process-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.process-attachments :deep(.el-image) { width: 76px; height: 76px; border: 1px solid #e2e8f0; border-radius: 6px; }
@media (max-width: 700px) {
.process-item { padding: 13px 12px 13px 18px; }
.process-heading { align-items: flex-start; flex-direction: column; gap: 5px; }
.process-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.process-summary div { min-width: 0; flex-direction: column; gap: 2px; }
.process-payload { grid-template-columns: 1fr; }
}
</style>
@@ -20,9 +20,11 @@ import {
adminResetHandoff, adminResetHandoff,
adminSealOrder, adminSealOrder,
fetchAdminHandoffRecords, fetchAdminHandoffRecords,
fetchAdminOrderProcessEvents,
fetchAdminOrder, fetchAdminOrder,
type HandoffRecord, type HandoffRecord,
type Order, type Order,
type ProcessEvent,
type RefundStatus, type RefundStatus,
} from '@/features/orders' } from '@/features/orders'
import { adminCreateOrderDispute } from '@/features/disputes' import { adminCreateOrderDispute } from '@/features/disputes'
@@ -44,6 +46,7 @@ import {
} from '@/shared/utils/statusLabels' } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time' import { formatDateTime } from '@/shared/utils/time'
import { formatGameName, formatListingNo } from '@/shared/utils/listingDisplay' import { formatGameName, formatListingNo } from '@/shared/utils/listingDisplay'
import ProcessTimeline from '../components/ProcessTimeline.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -51,6 +54,7 @@ const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const order = ref<Order | null>(null) const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([]) const handoffRecords = ref<HandoffRecord[]>([])
const processEvents = ref<ProcessEvent[]>([])
const paymentRecords = ref<AdminPayment[]>([]) const paymentRecords = ref<AdminPayment[]>([])
type OrderActionType = type OrderActionType =
| 'close' | 'close'
@@ -355,8 +359,14 @@ async function loadOrder() {
loading.value = true loading.value = true
try { try {
order.value = await fetchAdminOrder(String(route.params.id)) order.value = await fetchAdminOrder(String(route.params.id))
handoffRecords.value = await fetchAdminHandoffRecords(String(route.params.id)) const [handoffs, events, payments] = await Promise.all([
paymentRecords.value = (await fetchAdminPayments({ order_id: String(route.params.id) })).items fetchAdminHandoffRecords(String(route.params.id)),
fetchAdminOrderProcessEvents(String(route.params.id)),
fetchAdminPayments({ order_id: String(route.params.id) }),
])
handoffRecords.value = handoffs
processEvents.value = events
paymentRecords.value = payments.items
await loadRefundStatus() await loadRefundStatus()
} finally { } finally {
loading.value = false loading.value = false
@@ -783,6 +793,10 @@ function settlementModeLabel(mode?: string) {
return map[mode || 'owner_wallet'] || mode || '-' return map[mode || 'owner_wallet'] || mode || '-'
} }
function accountSourceLabel(source?: string) {
return source === 'external' ? '外部上传' : '站内上传'
}
function offlineSettlementStatusLabel(status?: string) { function offlineSettlementStatusLabel(status?: string) {
const map: Record<string, string> = { const map: Record<string, string> = {
none: '无需线下结算', none: '无需线下结算',
@@ -1132,6 +1146,34 @@ function paymentPaidAt(record: AdminPayment) {
</dl> </dl>
</section> </section>
<section class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>账号来源与处理方式</h2>
</div>
<dl class="detail-list">
<div>
<dt>账号来源</dt>
<dd>{{ accountSourceLabel(order.account_source) }}</dd>
</div>
<div v-if="order.source_channel">
<dt>外部渠道</dt>
<dd>{{ order.source_channel }}</dd>
</div>
<div>
<dt>交接责任</dt>
<dd>{{ handoffModeLabel(order.handoff_mode) }}</dd>
</div>
<div>
<dt>结算方式</dt>
<dd>{{ settlementModeLabel(order.settlement_mode) }}</dd>
</div>
<div v-if="order.managed_admin_id">
<dt>负责客服</dt>
<dd>ID {{ order.managed_admin_id }}</dd>
</div>
</dl>
</section>
<section v-if="isPlatformManaged" class="dashboard-panel detail-panel"> <section v-if="isPlatformManaged" class="dashboard-panel detail-panel">
<div class="panel-heading"> <div class="panel-heading">
<h2>平台代管</h2> <h2>平台代管</h2>
@@ -1373,6 +1415,17 @@ function paymentPaidAt(record: AdminPayment) {
</div> </div>
</section> </section>
<section class="dashboard-panel detail-panel order-wide-panel">
<div class="panel-heading">
<h2>交易全流程</h2>
<span class="panel-subtitle">每次提交修改确认及结算均单独留痕</span>
</div>
<ProcessTimeline
:events="processEvents"
empty-text="暂无新版过程记录旧订单仍可查看下方原始交接记录"
/>
</section>
<section class="dashboard-panel detail-panel order-wide-panel"> <section class="dashboard-panel detail-panel order-wide-panel">
<div class="panel-heading"> <div class="panel-heading">
<h2>交接记录</h2> <h2>交接记录</h2>
@@ -7,11 +7,13 @@ import { useRoute } from 'vue-router'
import { import {
fetchAdminPickup, fetchAdminPickup,
fetchAdminPickupFinancialAdjustments, fetchAdminPickupFinancialAdjustments,
fetchAdminPickupProcessEvents,
createAdminPickupFinancialAdjustment, createAdminPickupFinancialAdjustment,
settleAdminPickupFinancialAdjustment, settleAdminPickupFinancialAdjustment,
updateAdminPickupProfit, updateAdminPickupProfit,
type AdminPickup, type AdminPickup,
type AdminPickupFinancialAdjustment, type AdminPickupFinancialAdjustment,
type AdminPickupProcessEvent,
} from '@/features/admin/api/adminPickup' } from '@/features/admin/api/adminPickup'
import { quantity, readNumber, readUnitPrice } from '@/features/orders/composables/useOrderSnapshot' import { quantity, readNumber, readUnitPrice } from '@/features/orders/composables/useOrderSnapshot'
import { adminPath } from '@/shared/utils/adminPath' import { adminPath } from '@/shared/utils/adminPath'
@@ -24,6 +26,7 @@ import {
} from '@/shared/utils/money' } from '@/shared/utils/money'
import { pickupStatusLabel } from '@/shared/utils/statusLabels' import { pickupStatusLabel } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time' import { formatDateTime } from '@/shared/utils/time'
import ProcessTimeline from '../components/ProcessTimeline.vue'
interface SnapshotResource { interface SnapshotResource {
key: string key: string
@@ -38,6 +41,7 @@ const route = useRoute()
const loading = ref(false) const loading = ref(false)
const pickup = ref<AdminPickup | null>(null) const pickup = ref<AdminPickup | null>(null)
const financialAdjustments = ref<AdminPickupFinancialAdjustment[]>([]) const financialAdjustments = ref<AdminPickupFinancialAdjustment[]>([])
const processEvents = ref<AdminPickupProcessEvent[]>([])
const profitDialogVisible = ref(false) const profitDialogVisible = ref(false)
const financialAdjustmentDialogVisible = ref(false) const financialAdjustmentDialogVisible = ref(false)
const profitSaving = ref(false) const profitSaving = ref(false)
@@ -117,12 +121,14 @@ onMounted(loadPickup)
async function loadPickup() { async function loadPickup() {
loading.value = true loading.value = true
try { try {
const [item, adjustments] = await Promise.all([ const [item, adjustments, events] = await Promise.all([
fetchAdminPickup(String(route.params.id)), fetchAdminPickup(String(route.params.id)),
fetchAdminPickupFinancialAdjustments(String(route.params.id)), fetchAdminPickupFinancialAdjustments(String(route.params.id)),
fetchAdminPickupProcessEvents(String(route.params.id)),
]) ])
pickup.value = item pickup.value = item
financialAdjustments.value = adjustments financialAdjustments.value = adjustments
processEvents.value = events
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -467,6 +473,17 @@ function readSnapshotResources(summary: Record<string, unknown> | null): Snapsho
</dl> </dl>
</section> </section>
<section class="dashboard-panel detail-panel pickup-wide-panel">
<div class="panel-heading">
<h2>提号全流程</h2>
<span class="panel-subtitle">创建完成调整及线下结算均单独留痕</span>
</div>
<ProcessTimeline
:events="processEvents"
empty-text="暂无新版过程记录可先查看下方备注和财务调整记录"
/>
</section>
<section <section
v-if="financialAdjustments.length" v-if="financialAdjustments.length"
class="dashboard-panel detail-panel pickup-wide-panel" class="dashboard-panel detail-panel pickup-wide-panel"
@@ -46,6 +46,8 @@ export interface Order {
growth_points_awarded?: number growth_points_awarded?: number
growth_points_awarded_at?: string growth_points_awarded_at?: string
account_snapshot?: Record<string, unknown> account_snapshot?: Record<string, unknown>
account_source?: 'internal' | 'external' | string
source_channel?: string
listing_snapshot?: string listing_snapshot?: string
checkout_info?: string checkout_info?: string
counter_info?: string counter_info?: string
@@ -149,6 +151,27 @@ export interface HandoffRecord {
created_at: string created_at: string
} }
export interface ProcessEvent {
id: number
business_type: string
business_id: number
stage: string
action: string
actor_type: 'user' | 'admin' | 'system' | string
actor_id: number
actor_name: string
target_type: string
target_id?: number
target_name: string
content: string
reason: string
payload: Record<string, unknown>
attachment_urls: string[]
state_before: Record<string, unknown>
state_after: Record<string, unknown>
created_at: string
}
export interface PaymentOrder { export interface PaymentOrder {
id: number id: number
payment_no: string payment_no: string
@@ -288,6 +311,13 @@ export async function fetchHandoffRecords(id: string | number) {
return Array.isArray(data.data?.items) ? data.data.items : [] return Array.isArray(data.data?.items) ? data.data.items : []
} }
export async function fetchAdminOrderProcessEvents(id: string | number) {
const { data } = await apiClient.get<ApiResponse<{ items: ProcessEvent[] }>>(
`/admin/orders/${id}/process-events`
)
return Array.isArray(data.data?.items) ? data.data.items : []
}
export async function confirmReceive(id: number) { export async function confirmReceive(id: number) {
const { data } = await apiClient.post<ApiResponse<{ confirmed: boolean }>>( const { data } = await apiClient.post<ApiResponse<{ confirmed: boolean }>>(
`/orders/${id}/confirm-receive` `/orders/${id}/confirm-receive`