增加线下提号店铺管理

This commit is contained in:
yml2213
2026-07-26 18:19:12 +08:00
parent c594b543d1
commit 6b44ab599d
12 changed files with 176 additions and 2 deletions
+1
View File
@@ -17,6 +17,7 @@ type AdminPickup struct {
OwnerID uint64 `gorm:"not null;index" json:"owner_id"` OwnerID uint64 `gorm:"not null;index" json:"owner_id"`
AdminID uint64 `gorm:"not null" json:"admin_id"` AdminID uint64 `gorm:"not null" json:"admin_id"`
Platform string `gorm:"size:32;not null;default:''" json:"platform"` Platform string `gorm:"size:32;not null;default:''" json:"platform"`
ShopName string `gorm:"size:100;not null;default:'';index" json:"shop_name"`
ListingPriceCent int64 `gorm:"not null;default:0" json:"listing_price_cent"` ListingPriceCent int64 `gorm:"not null;default:0" json:"listing_price_cent"`
OwnerPriceCent int64 `gorm:"not null;default:0" json:"owner_price_cent"` OwnerPriceCent int64 `gorm:"not null;default:0" json:"owner_price_cent"`
WebsiteProfitCent int64 `gorm:"not null;default:0" json:"website_profit_cent"` WebsiteProfitCent int64 `gorm:"not null;default:0" json:"website_profit_cent"`
+3
View File
@@ -37,6 +37,7 @@ type PickupDTO struct {
OwnerPhone string `json:"owner_phone"` OwnerPhone string `json:"owner_phone"`
AdminID uint64 `json:"admin_id"` AdminID uint64 `json:"admin_id"`
Platform string `json:"platform"` Platform string `json:"platform"`
ShopName string `json:"shop_name"`
ListingPriceCent int64 `json:"listing_price_cent"` ListingPriceCent int64 `json:"listing_price_cent"`
OwnerPriceCent int64 `json:"owner_price_cent"` OwnerPriceCent int64 `json:"owner_price_cent"`
WebsiteProfitCent int64 `json:"website_profit_cent"` WebsiteProfitCent int64 `json:"website_profit_cent"`
@@ -56,6 +57,7 @@ type PickupDTO struct {
type CreateRequest struct { type CreateRequest struct {
ListingID uint64 `json:"listing_id" binding:"required"` ListingID uint64 `json:"listing_id" binding:"required"`
Platform string `json:"platform"` Platform string `json:"platform"`
ShopName string `json:"shop_name" binding:"max=100"`
ProfitAmountCent int64 `json:"profit_amount_cent"` ProfitAmountCent int64 `json:"profit_amount_cent"`
Remark string `json:"remark"` Remark string `json:"remark"`
} }
@@ -80,6 +82,7 @@ type AdminPickupQuery struct {
PageSize int PageSize int
Keyword string Keyword string
Status string Status string
ShopName string
} }
type SellerPickupQuery struct { type SellerPickupQuery struct {
@@ -122,6 +122,16 @@ func (h *Handler) List(c *gin.Context) {
response.OK(c, result) response.OK(c, result)
} }
// ShopOptions 返回后台提号已使用的店铺名称。
func (h *Handler) ShopOptions(c *gin.Context) {
items, err := h.service.ListShopNames(c.Request.Context(), c.Query("platform"))
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, items)
}
// Detail 提号订单详情(管理员) // Detail 提号订单详情(管理员)
func (h *Handler) Detail(c *gin.Context) { func (h *Handler) Detail(c *gin.Context) {
id, ok := parseID(c) id, ok := parseID(c)
@@ -58,6 +58,7 @@ func parseAdminQuery(c *gin.Context) AdminPickupQuery {
PageSize: pageSize, PageSize: pageSize,
Keyword: c.Query("keyword"), Keyword: c.Query("keyword"),
Status: c.Query("status"), Status: c.Query("status"),
ShopName: c.Query("shop_name"),
} }
} }
@@ -84,6 +84,7 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint
OwnerID: listing.OwnerID, OwnerID: listing.OwnerID,
AdminID: adminID, AdminID: adminID,
Platform: strings.TrimSpace(req.Platform), Platform: strings.TrimSpace(req.Platform),
ShopName: strings.TrimSpace(req.ShopName),
ListingPriceCent: priceSnapshot.ListingPriceCent, ListingPriceCent: priceSnapshot.ListingPriceCent,
OwnerPriceCent: priceSnapshot.OwnerPriceCent, OwnerPriceCent: priceSnapshot.OwnerPriceCent,
WebsiteProfitCent: priceSnapshot.WebsiteProfitCent, WebsiteProfitCent: priceSnapshot.WebsiteProfitCent,
@@ -145,6 +146,7 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint
"pickup_no": pickupNo, "pickup_no": pickupNo,
"listing_id": listing.ID, "listing_id": listing.ID,
"platform": pickup.Platform, "platform": pickup.Platform,
"shop_name": pickup.ShopName,
"profit_cent": pickup.ProfitAmountCent, "profit_cent": pickup.ProfitAmountCent,
}, },
}); err != nil { }); err != nil {
@@ -417,6 +419,9 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*Pa
if status := strings.TrimSpace(query.Status); status != "" { if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("p.status = ?", status) db = db.Where("p.status = ?", status)
} }
if shopName := strings.TrimSpace(query.ShopName); shopName != "" {
db = db.Where("p.shop_name = ?", shopName)
}
if kw := strings.TrimSpace(query.Keyword); kw != "" { if kw := strings.TrimSpace(query.Keyword); kw != "" {
like := "%" + kw + "%" like := "%" + kw + "%"
db = db.Where("p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ?", like, like, like) db = db.Where("p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ?", like, like, like)
@@ -424,6 +429,23 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*Pa
return r.paginatePickups(db, query.Page, query.PageSize, false) return r.paginatePickups(db, query.Page, query.PageSize, false)
} }
// ListShopNames 返回历史提号中已使用的店铺名称,供后台选择和筛选。
func (r *Repository) ListShopNames(ctx context.Context, platform string) ([]string, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
db := r.db.WithContext(ctx).Model(&model.AdminPickup{}).
Where("shop_name <> ''")
if value := strings.TrimSpace(platform); value != "" {
db = db.Where("platform = ?", value)
}
names := make([]string, 0)
if err := db.Distinct("shop_name").Order("shop_name ASC").Limit(200).Pluck("shop_name", &names).Error; err != nil {
return nil, err
}
return names, nil
}
func (r *Repository) ListForSeller(ctx context.Context, ownerID uint64, query SellerPickupQuery) (*PaginatedResult, error) { func (r *Repository) ListForSeller(ctx context.Context, ownerID uint64, query SellerPickupQuery) (*PaginatedResult, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
@@ -453,6 +475,7 @@ func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int, sellerView
item.WebsiteProfitCent = 0 item.WebsiteProfitCent = 0
item.ProfitAmountCent = 0 item.ProfitAmountCent = 0
item.BuyerRatio = 0 item.BuyerRatio = 0
item.ShopName = ""
} }
items = append(items, item) items = append(items, item)
} }
@@ -521,6 +544,7 @@ func (row pickupRow) toDTO() PickupDTO {
OwnerPhone: row.OwnerPhone, OwnerPhone: row.OwnerPhone,
AdminID: row.AdminID, AdminID: row.AdminID,
Platform: row.Platform, Platform: row.Platform,
ShopName: row.ShopName,
ListingPriceCent: row.ListingPriceCent, ListingPriceCent: row.ListingPriceCent,
OwnerPriceCent: row.OwnerPriceCent, OwnerPriceCent: row.OwnerPriceCent,
WebsiteProfitCent: row.WebsiteProfitCent, WebsiteProfitCent: row.WebsiteProfitCent,
@@ -125,6 +125,62 @@ func TestRepositoryUpdateProfitRejectsCompleted(t *testing.T) {
} }
} }
func TestRepositoryListAdminFiltersShopName(t *testing.T) {
repo, db := newPickupTestRepo(t)
first := seedPickup(t, db, StatusPickingUp)
if err := db.Model(&first).Updates(map[string]any{
"platform": "淘宝",
"shop_name": "大锤一店",
}).Error; err != nil {
t.Fatalf("设置第一条提号店铺失败: %v", err)
}
second := first
second.ID = 0
second.PickupNo = "PK-PROFIT-002"
second.ShopName = "大锤二店"
if err := db.Create(&second).Error; err != nil {
t.Fatalf("创建第二条提号记录失败: %v", err)
}
result, err := repo.ListAdmin(t.Context(), AdminPickupQuery{ShopName: " 大锤一店 "})
if err != nil {
t.Fatalf("ListAdmin() error = %v", err)
}
if result.Total != 1 || len(result.Items) != 1 {
t.Fatalf("店铺筛选结果 = total %d, items %d, want 1", result.Total, len(result.Items))
}
if result.Items[0].ShopName != "大锤一店" {
t.Fatalf("店铺名称 = %q, want 大锤一店", result.Items[0].ShopName)
}
}
func TestRepositoryListShopNamesReturnsDistinctPlatformOptions(t *testing.T) {
repo, db := newPickupTestRepo(t)
first := seedPickup(t, db, StatusPickingUp)
if err := db.Model(&first).Updates(map[string]any{
"platform": "淘宝",
"shop_name": "大锤一店",
}).Error; err != nil {
t.Fatalf("设置第一条提号店铺失败: %v", err)
}
for index, row := range []model.AdminPickup{
{PickupNo: "PK-SHOP-002", ListingID: first.ListingID, AccountID: first.AccountID, OwnerID: first.OwnerID, AdminID: 1, Platform: "淘宝", ShopName: "大锤一店", Status: StatusPickingUp},
{PickupNo: "PK-SHOP-003", ListingID: first.ListingID, AccountID: first.AccountID, OwnerID: first.OwnerID, AdminID: 1, Platform: "拼多多", ShopName: "大锤二店", Status: StatusPickingUp},
} {
if err := db.Create(&row).Error; err != nil {
t.Fatalf("创建第 %d 条店铺选项失败: %v", index+2, err)
}
}
names, err := repo.ListShopNames(t.Context(), "淘宝")
if err != nil {
t.Fatalf("ListShopNames() error = %v", err)
}
if len(names) != 1 || names[0] != "大锤一店" {
t.Fatalf("淘宝店铺选项 = %v, want [大锤一店]", names)
}
}
func assertInt64(t *testing.T, name string, got, want int64) { func assertInt64(t *testing.T, name string, got, want int64) {
t.Helper() t.Helper()
if got != want { if got != want {
@@ -77,6 +77,13 @@ func (s *Service) ListAdmin(ctx context.Context, query AdminPickupQuery) (*Pagin
return s.repo.ListAdmin(ctx, query) return s.repo.ListAdmin(ctx, query)
} }
func (s *Service) ListShopNames(ctx context.Context, platform string) ([]string, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListShopNames(ctx, platform)
}
func (s *Service) ListForSeller(ctx context.Context, ownerID uint64, query SellerPickupQuery) (*PaginatedResult, error) { func (s *Service) ListForSeller(ctx context.Context, ownerID uint64, query SellerPickupQuery) (*PaginatedResult, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
+1
View File
@@ -594,6 +594,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
// 管理员线下提号(独立于正常订单流程) // 管理员线下提号(独立于正常订单流程)
adminRoutes.POST("/pickups", requirePerm("order:pickup"), pickupHandler.Create) adminRoutes.POST("/pickups", requirePerm("order:pickup"), pickupHandler.Create)
adminRoutes.GET("/pickups", requirePerm("order:pickup"), pickupHandler.List) adminRoutes.GET("/pickups", requirePerm("order:pickup"), pickupHandler.List)
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", 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,11 @@
-- +goose Up
ALTER TABLE admin_pickups
ADD COLUMN shop_name VARCHAR(100) NOT NULL DEFAULT '' COMMENT '线下成交店铺名称' AFTER platform,
ADD KEY idx_admin_pickups_shop_name (shop_name);
-- +goose Down
ALTER TABLE admin_pickups
DROP KEY idx_admin_pickups_shop_name,
DROP COLUMN shop_name;
@@ -14,6 +14,7 @@ export interface AdminPickup {
owner_phone: string owner_phone: string
admin_id: number admin_id: number
platform: string platform: string
shop_name: string
listing_price_cent: number listing_price_cent: number
owner_price_cent: number owner_price_cent: number
website_profit_cent: number website_profit_cent: number
@@ -52,6 +53,7 @@ export interface AvailableListing {
export interface AdminPickupCreateRequest { export interface AdminPickupCreateRequest {
listing_id: number listing_id: number
platform?: string platform?: string
shop_name?: string
profit_amount_cent?: number profit_amount_cent?: number
remark?: string remark?: string
} }
@@ -72,6 +74,7 @@ export interface AdminPickupListQuery {
page_size?: number page_size?: number
keyword?: string keyword?: string
status?: string status?: string
shop_name?: string
} }
function cleanParams(query: object) { function cleanParams(query: object) {
@@ -99,6 +102,13 @@ export async function fetchAdminPickup(id: string | number) {
return data.data return data.data
} }
export async function fetchAdminPickupShopOptions(platform = '') {
const { data } = await apiClient.get<ApiResponse<string[]>>('/admin/pickups/shop-options', {
params: cleanParams({ platform }),
})
return Array.isArray(data.data) ? data.data : []
}
export async function fetchAvailableListings(keyword: string, page = 1, page_size = 20) { export async function fetchAvailableListings(keyword: string, page = 1, page_size = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AvailableListing>>>( const { data } = await apiClient.get<ApiResponse<PaginatedResult<AvailableListing>>>(
'/admin/pickups/available-listings', '/admin/pickups/available-listings',
@@ -300,6 +300,10 @@ function readSnapshotResources(summary: Record<string, unknown> | null): Snapsho
<dt>交易渠道</dt> <dt>交易渠道</dt>
<dd>{{ pickup.platform || '-' }}</dd> <dd>{{ pickup.platform || '-' }}</dd>
</div> </div>
<div>
<dt>店铺名称</dt>
<dd>{{ pickup.shop_name || '-' }}</dd>
</div>
<div> <div>
<dt>管理员ID</dt> <dt>管理员ID</dt>
<dd>{{ pickup.admin_id }}</dd> <dd>{{ pickup.admin_id }}</dd>
@@ -7,6 +7,7 @@ import {
completeAdminPickup, completeAdminPickup,
createAdminPickup, createAdminPickup,
fetchAdminPickups, fetchAdminPickups,
fetchAdminPickupShopOptions,
fetchAvailableListings, fetchAvailableListings,
updateAdminPickupProfit, updateAdminPickupProfit,
type AdminPickup, type AdminPickup,
@@ -20,7 +21,7 @@ import { adminPath } from '@/shared/utils/adminPath'
const loading = ref(false) const loading = ref(false)
const items = ref<AdminPickup[]>([]) const items = ref<AdminPickup[]>([])
const total = ref(0) const total = ref(0)
const filters = reactive({ page: 1, page_size: 20, keyword: '', status: '' }) const filters = reactive({ page: 1, page_size: 20, keyword: '', status: '', shop_name: '' })
const createDialogVisible = ref(false) const createDialogVisible = ref(false)
const completeDialogVisible = ref(false) const completeDialogVisible = ref(false)
@@ -31,6 +32,7 @@ const profitSaving = ref(false)
const createForm = reactive({ const createForm = reactive({
listing_id: null as number | null, listing_id: null as number | null,
platform: '', platform: '',
shop_name: '',
profit_amount: 0, profit_amount: 0,
remark: '', remark: '',
}) })
@@ -39,6 +41,8 @@ const profitForm = reactive({ profit_amount: 0, reason: '' })
const listingOptions = ref<AvailableListing[]>([]) const listingOptions = ref<AvailableListing[]>([])
const listingLoading = ref(false) const listingLoading = ref(false)
const shopOptions = ref<string[]>([])
const shopOptionsLoading = ref(false)
const selectedListing = computed( const selectedListing = computed(
() => listingOptions.value.find(item => item.id === createForm.listing_id) || null () => listingOptions.value.find(item => item.id === createForm.listing_id) || null
) )
@@ -58,7 +62,10 @@ const selectedPriceSnapshot = computed(() => {
return { pureCent, extraCent, totalCent } return { pureCent, extraCent, totalCent }
}) })
onMounted(loadList) onMounted(() => {
loadList()
loadShopOptions()
})
async function loadList() { async function loadList() {
loading.value = true loading.value = true
@@ -88,12 +95,22 @@ function handleSizeChange(s: number) {
function openCreateDialog() { function openCreateDialog() {
createForm.listing_id = null createForm.listing_id = null
createForm.platform = '' createForm.platform = ''
createForm.shop_name = ''
createForm.profit_amount = 0 createForm.profit_amount = 0
createForm.remark = '' createForm.remark = ''
listingOptions.value = [] listingOptions.value = []
createDialogVisible.value = true createDialogVisible.value = true
} }
async function loadShopOptions() {
shopOptionsLoading.value = true
try {
shopOptions.value = await fetchAdminPickupShopOptions()
} finally {
shopOptionsLoading.value = false
}
}
async function searchListings(keyword: string) { async function searchListings(keyword: string) {
if (!keyword) { if (!keyword) {
listingOptions.value = [] listingOptions.value = []
@@ -122,11 +139,13 @@ async function handleCreate() {
await createAdminPickup({ await createAdminPickup({
listing_id: createForm.listing_id, listing_id: createForm.listing_id,
platform: createForm.platform, platform: createForm.platform,
shop_name: createForm.shop_name,
profit_amount_cent: yuanToCent(createForm.profit_amount), profit_amount_cent: yuanToCent(createForm.profit_amount),
remark: createForm.remark, remark: createForm.remark,
}) })
ElMessage.success('提号订单已创建') ElMessage.success('提号订单已创建')
createDialogVisible.value = false createDialogVisible.value = false
loadShopOptions()
loadList() loadList()
} catch (e: unknown) { } catch (e: unknown) {
ElMessage.error(errorMessage(e) || '创建失败') ElMessage.error(errorMessage(e) || '创建失败')
@@ -278,6 +297,15 @@ function listingOwnerTotalCent(item: AvailableListing) {
<el-option label="已完成" value="completed" /> <el-option label="已完成" value="completed" />
<el-option label="已取消" value="cancelled" /> <el-option label="已取消" value="cancelled" />
</el-select> </el-select>
<el-select
v-model="filters.shop_name"
placeholder="店铺名称"
filterable
clearable
style="width: 160px"
>
<el-option v-for="name in shopOptions" :key="name" :label="name" :value="name" />
</el-select>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button> <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="loadList">刷新</el-button> <el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div> </div>
@@ -299,6 +327,9 @@ function listingOwnerTotalCent(item: AvailableListing) {
<el-table-column label="交易渠道" width="110"> <el-table-column label="交易渠道" width="110">
<template #default="{ row }">{{ row.platform || '-' }}</template> <template #default="{ row }">{{ row.platform || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="店铺名称" min-width="130">
<template #default="{ row }">{{ row.shop_name || '-' }}</template>
</el-table-column>
<el-table-column label="结算金额" width="130"> <el-table-column label="结算金额" width="130">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row.status === 'completed'">{{ <span v-if="row.status === 'completed'">{{
@@ -407,6 +438,21 @@ function listingOwnerTotalCent(item: AvailableListing) {
<el-form-item label="交易渠道"> <el-form-item label="交易渠道">
<el-input v-model="createForm.platform" placeholder="如 微信 / QQ / 闲鱼" /> <el-input v-model="createForm.platform" placeholder="如 微信 / QQ / 闲鱼" />
</el-form-item> </el-form-item>
<el-form-item label="店铺名称">
<el-select
v-model="createForm.shop_name"
filterable
allow-create
default-first-option
clearable
:loading="shopOptionsLoading"
placeholder="选择历史店铺或输入新店铺"
style="width: 100%"
>
<el-option v-for="name in shopOptions" :key="name" :label="name" :value="name" />
</el-select>
<div class="form-hint">首次使用的店铺可直接输入名称创建</div>
</el-form-item>
<el-form-item label="线下利润(元)"> <el-form-item label="线下利润(元)">
<el-input-number <el-input-number
v-model="createForm.profit_amount" v-model="createForm.profit_amount"