feat(admin-order): 订单管理列表恢复号主实际收入列
在金额左侧展示已结账/已仲裁订单的到手金额;整页一条批量 SQL 取最新结账行,避免 N+1。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -125,6 +125,9 @@ type AdminOrderListItemDTO struct {
|
||||
SettlementMode string `json:"settlement_mode"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// OwnerIncomeAmountCent 为号主实际到手金额(分),仅在已结账/已仲裁订单上返回;
|
||||
// 用指针区分「未结账(null)」与「到手 0 元」。数据来自结账表最新一行。
|
||||
OwnerIncomeAmountCent *int64 `json:"owner_income_amount_cent,omitempty"`
|
||||
}
|
||||
|
||||
type AdminActionsDTO struct {
|
||||
|
||||
@@ -173,9 +173,29 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Adm
|
||||
}
|
||||
}
|
||||
|
||||
// 号主实际收入:按需收集本页行号,一次性批量查询(单条 SQL),避免逐行查询的 N+1。
|
||||
ownerIncome := make(map[uint64]int64)
|
||||
incomeOrderIDs := make([]uint64, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if adminListItemNeedsOwnerIncome(row) {
|
||||
incomeOrderIDs = append(incomeOrderIDs, row.ID)
|
||||
}
|
||||
}
|
||||
if len(incomeOrderIDs) > 0 {
|
||||
ownerIncome, err = r.latestOwnerIncomeByOrder(ctx, incomeOrderIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]AdminOrderListItemDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, row.toAdminListItem())
|
||||
item := row.toAdminListItem()
|
||||
if amount, ok := ownerIncome[row.ID]; ok {
|
||||
value := amount
|
||||
item.OwnerIncomeAmountCent = &value
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return &AdminOrderListPaginatedResult{
|
||||
@@ -218,38 +238,57 @@ func (r *Repository) adminOrderCountQuery(ctx context.Context, query AdminOrderQ
|
||||
return r.adminOrderJoinQuery(ctx)
|
||||
}
|
||||
|
||||
// latestAdminCheckouts loads one latest checkout per order so the admin order
|
||||
// list can display finalized income without issuing one query per row.
|
||||
func (r *Repository) latestAdminCheckouts(ctx context.Context, rows []orderRow) (map[uint64]model.OrderCheckout, error) {
|
||||
result := make(map[uint64]model.OrderCheckout, len(rows))
|
||||
if len(rows) == 0 {
|
||||
// latestOwnerIncomeByOrder 批量读取每单「最新一次结账」的号主实际到手金额(分)。
|
||||
//
|
||||
// 后台订单列表专用:一次查询覆盖整页,不会退化为逐行查询的 N+1。
|
||||
// 只取结账表两列,不加载 content / evidence_urls 等大字段。
|
||||
//
|
||||
// 语义:多轮还价是原地更新同一行,但订单可能经历多次结账流程,
|
||||
// 因此按 order_id 取 MAX(id) 作为最终结账行。
|
||||
//
|
||||
// 执行计划(MySQL 8.4 实测,EXPLAIN FORMAT=TREE):
|
||||
//
|
||||
// -> Nested loop inner join
|
||||
// -> Materialize
|
||||
// -> Group aggregate: max(order_checkouts.id)
|
||||
// -> Covering index scan on order_checkouts using idx_order_checkouts_order_id
|
||||
// -> Single-row index lookup on oc using PRIMARY (id=latest.latest_id)
|
||||
//
|
||||
// 用 STRAIGHT_JOIN 固定连接顺序:先物化「每单最新结账 id」小集合(走
|
||||
// idx_order_checkouts_order_id 覆盖索引),再按主键逐行取金额。避免优化器
|
||||
// 在数据量增长后改选全表扫描;开销只与当前页订单数相关,与结账表总量无关。
|
||||
func (r *Repository) latestOwnerIncomeByOrder(ctx context.Context, orderIDs []uint64) (map[uint64]int64, error) {
|
||||
result := make(map[uint64]int64, len(orderIDs))
|
||||
if len(orderIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
orderIDs := make([]uint64, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
orderIDs = append(orderIDs, row.ID)
|
||||
}
|
||||
latest := r.db.WithContext(ctx).
|
||||
Table("order_checkouts").
|
||||
Select("MAX(id) AS id").
|
||||
Select("order_id, MAX(id) AS latest_id").
|
||||
Where("order_id IN ?", orderIDs).
|
||||
Group("order_id")
|
||||
var checkouts []model.OrderCheckout
|
||||
var rows []struct {
|
||||
OrderID uint64
|
||||
OwnerIncomeAmountCent int64
|
||||
}
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("order_checkouts AS oc").
|
||||
Joins("JOIN (?) AS latest ON latest.id = oc.id", latest).
|
||||
Find(&checkouts).Error; err != nil {
|
||||
Table("(?) AS latest", latest).
|
||||
Select("oc.order_id, oc.owner_income_amount_cent").
|
||||
Joins("STRAIGHT_JOIN order_checkouts AS oc ON oc.id = latest.latest_id").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, checkout := range checkouts {
|
||||
result[checkout.OrderID] = checkout
|
||||
for _, row := range rows {
|
||||
result[row.OrderID] = row.OwnerIncomeAmountCent
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func shouldAttachAdminCheckout(row orderRow) bool {
|
||||
return shouldAttachCheckout(row.Status) ||
|
||||
row.SettlementStatus == settlementStatusSettled ||
|
||||
// adminListItemNeedsOwnerIncome 判断列表行是否需要附带号主实际收入。
|
||||
// 仅已结账/已仲裁时库中金额为最终值;协商中的金额会随后续还价变化,
|
||||
// 不作为列表结论展示(协商中的实时金额走详情接口)。
|
||||
func adminListItemNeedsOwnerIncome(row orderRow) bool {
|
||||
return row.SettlementStatus == settlementStatusSettled ||
|
||||
row.SettlementStatus == settlementStatusArbitrated
|
||||
}
|
||||
|
||||
@@ -415,15 +454,6 @@ func effectiveDisputeInitiatorType(row model.Dispute) string {
|
||||
return "user"
|
||||
}
|
||||
|
||||
func shouldAttachCheckout(status string) bool {
|
||||
switch status {
|
||||
case orderStatusPendingCheckoutConfirm, orderStatusPendingCheckoutAccept, orderStatusCheckoutDisputing, orderStatusCompleted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) latestCheckoutDTOForUser(ctx context.Context, orderID uint64, userID uint64, order model.RentalOrder) *CheckoutDTO {
|
||||
var checkout model.OrderCheckout
|
||||
if err := r.db.WithContext(ctx).Where("order_id = ?", orderID).Order("id DESC").First(&checkout).Error; err != nil {
|
||||
|
||||
@@ -3,6 +3,8 @@ package order
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// setupOrderTestDB 创建订单测试数据库。
|
||||
@@ -2157,6 +2160,8 @@ func createOpenPlatformCheckout(t *testing.T, db *gorm.DB, order model.RentalOrd
|
||||
return checkout
|
||||
}
|
||||
|
||||
// TestListAdminUsesFinalizedCheckoutOwnerIncome 校验后台订单列表带回「号主实际收入」,
|
||||
// 且金额取自结账表最终行(已结账/已仲裁时才是定论值)。
|
||||
func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
@@ -2181,10 +2186,82 @@ func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) {
|
||||
if len(result.Items) != 1 {
|
||||
t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items))
|
||||
}
|
||||
// 订单列表已最小化为 AdminOrderListItemDTO,结账明细(含号主到手金额)由订单详情接口返回。
|
||||
if result.Items[0].SettlementStatus != settlementStatusSettled {
|
||||
t.Fatalf("settlement status = %q, want %q", result.Items[0].SettlementStatus, settlementStatusSettled)
|
||||
}
|
||||
// 列表需直接展示号主实际收入,供客服在列表页核对结算。
|
||||
income := result.Items[0].OwnerIncomeAmountCent
|
||||
if income == nil {
|
||||
t.Fatalf("owner income = nil, want 6000 for settled order")
|
||||
}
|
||||
if *income != 6000 {
|
||||
t.Fatalf("owner income = %d, want 6000", *income)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAdminOmitsOwnerIncomeWhileNegotiating 校验协商中的订单不返回号主实际收入:
|
||||
// 此时金额还会随还价变化,不能作为列表结论展示(未结账与「到手 0 元」必须可区分)。
|
||||
func TestListAdminOmitsOwnerIncomeWhileNegotiating(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
adminID := uint64(7003)
|
||||
_, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
|
||||
checkout := createOpenPlatformCheckout(t, db, order, renter.ID)
|
||||
checkout.OwnerIncomeAmountCent = 5000
|
||||
if err := db.Save(&checkout).Error; err != nil {
|
||||
t.Fatalf("save negotiating checkout failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAdmin() error = %v", err)
|
||||
}
|
||||
if len(result.Items) != 1 {
|
||||
t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items))
|
||||
}
|
||||
if result.Items[0].SettlementStatus == settlementStatusSettled {
|
||||
t.Fatalf("precondition failed: order should not be settled")
|
||||
}
|
||||
if result.Items[0].OwnerIncomeAmountCent != nil {
|
||||
t.Fatalf("owner income = %d, want nil while checkout is still negotiating", *result.Items[0].OwnerIncomeAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAdminOwnerIncomeUsesLatestCheckout 校验一单存在多行结账记录时取最新一行(MAX(id))。
|
||||
func TestListAdminOwnerIncomeUsesLatestCheckout(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
adminID := uint64(7004)
|
||||
_, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
|
||||
order.Status = orderStatusCompleted
|
||||
order.SettlementStatus = settlementStatusSettled
|
||||
if err := db.Save(&order).Error; err != nil {
|
||||
t.Fatalf("save completed order failed: %v", err)
|
||||
}
|
||||
first := createOpenPlatformCheckout(t, db, order, renter.ID)
|
||||
first.Status = checkoutStatusAccepted
|
||||
first.OwnerIncomeAmountCent = 1111
|
||||
if err := db.Save(&first).Error; err != nil {
|
||||
t.Fatalf("save first checkout failed: %v", err)
|
||||
}
|
||||
second := createOpenPlatformCheckout(t, db, order, renter.ID)
|
||||
second.Status = checkoutStatusAccepted
|
||||
second.OwnerIncomeAmountCent = 2222
|
||||
if err := db.Save(&second).Error; err != nil {
|
||||
t.Fatalf("save second checkout failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAdmin() error = %v", err)
|
||||
}
|
||||
if len(result.Items) != 1 {
|
||||
t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items))
|
||||
}
|
||||
income := result.Items[0].OwnerIncomeAmountCent
|
||||
if income == nil || *income != 2222 {
|
||||
t.Fatalf("owner income = %v, want 2222 from latest checkout row", income)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPlatformCheckoutConfirmRequiresExplicitConfirmationForZeroOwnerIncome(t *testing.T) {
|
||||
@@ -2385,3 +2462,166 @@ func TestListForUserAndFindForUserSiteIsolation(t *testing.T) {
|
||||
t.Fatalf("expected PendingHandoff metric = 1, got %d", handoffList.Metrics.PendingHandoff)
|
||||
}
|
||||
}
|
||||
|
||||
// countingQueryLogger 统计实际执行的 SQL 条数,用于断言查询不随页内行数增长(防 N+1)。
|
||||
type countingQueryLogger struct {
|
||||
logger.Interface
|
||||
queries int
|
||||
}
|
||||
|
||||
func (c *countingQueryLogger) Trace(context.Context, time.Time, func() (string, int64), error) {
|
||||
c.queries++
|
||||
}
|
||||
|
||||
// seedSettledAdminOrders 造 [from, to) 区间的「已结账」订单,供列表分页与 N+1 断言使用。
|
||||
// 每单独立 owner/renter(users.phone 唯一,故用序号区分),结账金额为 1000+i,便于逐行核对。
|
||||
func seedSettledAdminOrders(t *testing.T, db *gorm.DB, from, to int) {
|
||||
t.Helper()
|
||||
adminID := uint64(7099)
|
||||
for i := from; i < to; i++ {
|
||||
owner := model.User{Phone: fmt.Sprintf("1390001%04d", i)}
|
||||
renter := model.User{Phone: fmt.Sprintf("1390002%04d", i)}
|
||||
if err := db.Create(&owner).Error; err != nil {
|
||||
t.Fatalf("create owner %d failed: %v", i, err)
|
||||
}
|
||||
if err := db.Create(&renter).Error; err != nil {
|
||||
t.Fatalf("create renter %d failed: %v", i, err)
|
||||
}
|
||||
account := model.GameAccount{
|
||||
OwnerID: owner.ID,
|
||||
Status: accountStatusRented,
|
||||
ServerRegion: "国服",
|
||||
LoginPlatform: "steam",
|
||||
Title: fmt.Sprintf("压测账号%d", i),
|
||||
}
|
||||
if err := db.Create(&account).Error; err != nil {
|
||||
t.Fatalf("create account %d failed: %v", i, err)
|
||||
}
|
||||
listing := model.RentalListing{
|
||||
ListingNo: fmt.Sprintf("LST-N1-%04d", i),
|
||||
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 %d failed: %v", i, err)
|
||||
}
|
||||
order := model.RentalOrder{
|
||||
OrderNo: fmt.Sprintf("ORD-N1-%04d", i),
|
||||
ListingID: listing.ID,
|
||||
AccountID: account.ID,
|
||||
OwnerID: owner.ID,
|
||||
RenterID: renter.ID,
|
||||
Status: orderStatusCompleted,
|
||||
SettlementStatus: settlementStatusSettled,
|
||||
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}`)),
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("create order %d failed: %v", i, err)
|
||||
}
|
||||
checkout := model.OrderCheckout{
|
||||
OrderID: order.ID,
|
||||
InitiatedBy: renter.ID,
|
||||
Status: checkoutStatusAccepted,
|
||||
RoundCount: 1,
|
||||
Turn: checkoutTurnOwner,
|
||||
ProposedBy: renter.ID,
|
||||
OwnerIncomeAmountCent: int64(1000 + i),
|
||||
}
|
||||
if err := db.Create(&checkout).Error; err != nil {
|
||||
t.Fatalf("create checkout %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAdminOwnerIncomeDoesNotScaleQueriesWithRows 断言「号主实际收入」是整页一次批量查询:
|
||||
// 同一页内订单数从 1 增到 5,SQL 条数必须持平;若退化为逐行查询,条数会随行数增长。
|
||||
func TestListAdminOwnerIncomeDoesNotScaleQueriesWithRows(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
seedSettledAdminOrders(t, db, 0, 1)
|
||||
|
||||
counter := &countingQueryLogger{Interface: logger.Default.LogMode(logger.Silent)}
|
||||
repo := NewRepository(db.Session(&gorm.Session{Logger: counter}))
|
||||
|
||||
runOnce := func(wantItems int) int {
|
||||
counter.queries = 0
|
||||
result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAdmin() error = %v", err)
|
||||
}
|
||||
if len(result.Items) != wantItems {
|
||||
t.Fatalf("ListAdmin() returned %d items, want %d", len(result.Items), wantItems)
|
||||
}
|
||||
for _, item := range result.Items {
|
||||
if item.OwnerIncomeAmountCent == nil {
|
||||
t.Fatalf("order %d: owner income nil, want settled amount", item.ID)
|
||||
}
|
||||
}
|
||||
return counter.queries
|
||||
}
|
||||
|
||||
withOneRow := runOnce(1)
|
||||
|
||||
// 追加 4 单(共 5 单,仍在一页内且不足 pageSize,COUNT 被首页优化跳过)。
|
||||
seedSettledAdminOrders(t, db, 1, 5)
|
||||
withFiveRows := runOnce(5)
|
||||
|
||||
if withOneRow <= 0 {
|
||||
t.Fatalf("查询计数未生效(计数为 %d),断言会空过", withOneRow)
|
||||
}
|
||||
if withFiveRows != withOneRow {
|
||||
t.Fatalf("列表 SQL 条数随行数增长(疑似 N+1):1 单=%d 条,5 单=%d 条", withOneRow, withFiveRows)
|
||||
}
|
||||
}
|
||||
|
||||
// sqlCaptureLogger 记录执行过的 SQL 文本,用于断言语句形态。
|
||||
type sqlCaptureLogger struct {
|
||||
logger.Interface
|
||||
statements []string
|
||||
}
|
||||
|
||||
func (c *sqlCaptureLogger) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
|
||||
sql, _ := fc()
|
||||
c.statements = append(c.statements, sql)
|
||||
}
|
||||
|
||||
// TestLatestOwnerIncomeQueryIsSingleBatchStatement 锁定号主实际收入的取数形态:
|
||||
// 整页一条语句、用 STRAIGHT_JOIN 固定「先物化最新结账 id,再按主键取金额」的顺序。
|
||||
// 该形态对应 EXPLAIN:覆盖索引扫描 + 主键单行查找,不随行数产生额外查询。
|
||||
func TestLatestOwnerIncomeQueryIsSingleBatchStatement(t *testing.T) {
|
||||
capture := &sqlCaptureLogger{Interface: logger.Default.LogMode(logger.Silent)}
|
||||
db := setupOrderTestDB(t).Session(&gorm.Session{Logger: capture})
|
||||
repo := NewRepository(db)
|
||||
|
||||
if _, err := repo.latestOwnerIncomeByOrder(t.Context(), []uint64{11, 12, 13}); err != nil {
|
||||
t.Fatalf("latestOwnerIncomeByOrder() error = %v", err)
|
||||
}
|
||||
if len(capture.statements) != 1 {
|
||||
t.Fatalf("取数语句条数 = %d, want 1(多行必须合并为单条批量查询)", len(capture.statements))
|
||||
}
|
||||
sql := capture.statements[0]
|
||||
if !strings.Contains(sql, "STRAIGHT_JOIN order_checkouts") {
|
||||
t.Fatalf("缺少 STRAIGHT_JOIN 固定连接顺序,实际 SQL:\n%s", sql)
|
||||
}
|
||||
if !strings.Contains(sql, "GROUP BY") {
|
||||
t.Fatalf("缺少按 order_id 分组的 MAX(id) 子查询,实际 SQL:\n%s", sql)
|
||||
}
|
||||
if strings.Contains(sql, "SELECT *") {
|
||||
t.Fatalf("应只取所需列,避免读取 content/evidence_urls 大字段,实际 SQL:\n%s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,11 @@ function userText(value: string | number | undefined) {
|
||||
function isPlatformManaged(row: AdminOrderListItem) {
|
||||
return row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed'
|
||||
}
|
||||
|
||||
function ownerActualIncome(row: AdminOrderListItem) {
|
||||
const value = row.owner_income_amount_cent
|
||||
return typeof value === 'number' ? value : null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -323,6 +328,14 @@ function isPlatformManaged(row: AdminOrderListItem) {
|
||||
<el-table-column label="结算" width="110">
|
||||
<template #default="{ row }">{{ settlementStatusLabel(row.settlement_status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="号主实际收入" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<strong v-if="ownerActualIncome(row) !== null" class="owner-income-cell">
|
||||
¥{{ money(amountYuan(ownerActualIncome(row))) }}
|
||||
</strong>
|
||||
<span v-else class="owner-income-empty">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<div class="stacked-cell money-cell">
|
||||
|
||||
@@ -117,6 +117,8 @@ export interface AdminOrderListItem {
|
||||
settlement_mode: string
|
||||
settlement_status: SettlementStatus
|
||||
created_at: string
|
||||
// 已结账/已仲裁时返回号主实际到手金额(分);未结账为 undefined,与到手 0 元可区分。
|
||||
owner_income_amount_cent?: number
|
||||
}
|
||||
|
||||
export interface SellerHandoffMetrics {
|
||||
|
||||
Reference in New Issue
Block a user