feat(supply-chain): 限价列表按负责人限价降序
This commit is contained in:
parent
793e72bd27
commit
ea9851fd98
@ -13,6 +13,7 @@ import {
|
||||
const limitProductsQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(200).default(50),
|
||||
sort: z.enum(['limitPriceDesc']).default('limitPriceDesc'),
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@ -22,6 +23,7 @@ export async function GET(request: NextRequest) {
|
||||
const parsed = limitProductsQuerySchema.safeParse({
|
||||
page: request.nextUrl.searchParams.get('page') ?? undefined,
|
||||
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
|
||||
sort: request.nextUrl.searchParams.get('sort') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分页参数不正确', 400, ctx.requestId)
|
||||
@ -53,6 +55,7 @@ export async function POST(request: NextRequest) {
|
||||
const parsed = limitProductsQuerySchema.safeParse({
|
||||
page: request.nextUrl.searchParams.get('page') ?? undefined,
|
||||
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
|
||||
sort: request.nextUrl.searchParams.get('sort') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分页参数不正确', 400, ctx.requestId)
|
||||
|
||||
@ -154,7 +154,10 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
|
||||
const [productKind, setProductKind] = useState<ProductKind>('bulk')
|
||||
const [filters, setFilters] = useState<ProductArchiveFilters>(EMPTY_FILTERS)
|
||||
const [comboFilters, setComboFilters] = useState<ComboLimitFilters>({})
|
||||
const [comboSort, setComboSort] = useState<ComboLimitSort>(null)
|
||||
const [comboSort, setComboSort] = useState<ComboLimitSort>({
|
||||
field: 'ownerLimitPrice',
|
||||
direction: 'desc',
|
||||
})
|
||||
// 列宽拖拽:拖动过程中用本地临时宽度,松开鼠标后一次性持久化到个人设置。
|
||||
const [liveWidths, setLiveWidths] = useState<Record<string, number> | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
@ -163,7 +166,10 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
|
||||
const [brandPartitionSettled, setBrandPartitionSettled] = useState(false)
|
||||
const [imagePreview, setImagePreview] = useState<{ image: string; title: string } | null>(null)
|
||||
const [openFilterKey, setOpenFilterKey] = useState<string | null>(null)
|
||||
const [sort, setSort] = useState<LimitTableSort>(null)
|
||||
const [sort, setSort] = useState<LimitTableSort>({
|
||||
key: 'ownerLimitPrice',
|
||||
direction: 'desc',
|
||||
})
|
||||
const [openSortKey, setOpenSortKey] = useState<string | null>(null)
|
||||
|
||||
// 大货/品牌两个子界面的划分依据:品牌字段(含顾家/索菲亚/朴美 → 品牌;其余 → 大货)
|
||||
|
||||
@ -1,11 +1,31 @@
|
||||
import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE, PRODUCT_ARCHIVE_SALES_STATUSES } from '@/lib/supply-chain-product-archive'
|
||||
import { buildProductArchiveWhere } from '../supply-chain-product-archive.repository'
|
||||
import {
|
||||
buildProductArchiveWhere,
|
||||
sortLimitPricedArchiveCandidates,
|
||||
} from '../supply-chain-product-archive.repository'
|
||||
import { stripProductArchiveBlockedFilters } from '@/lib/product-archive-field-permissions'
|
||||
import { PRODUCT_ARCHIVE_KEYWORD_FIELDS } from '@/lib/supply-chain-product-archive'
|
||||
import { parseProductArchiveFiltersPayload } from '@/server/services/supply-chain-product-archive-query'
|
||||
import { PRODUCT_ARCHIVE_IMAGE_SEARCH_ELIGIBLE_WHERE } from '../product-archive-image-search.repository'
|
||||
|
||||
describe('supply-chain product archive query filters', () => {
|
||||
it('sorts by owner limit price descending and puts unpriced products last', () => {
|
||||
const rows = [
|
||||
{ id: 'none', sku: 'NONE', barcode: 'NONE', businessCreatedAt: new Date('2026-10-03') },
|
||||
{ id: 'low', sku: 'LOW', barcode: 'LOW', businessCreatedAt: new Date('2026-10-02') },
|
||||
{ id: 'high-old', sku: 'HIGH', barcode: 'HIGH-1', businessCreatedAt: new Date('2026-10-01') },
|
||||
{ id: 'high-new', sku: 'HIGH', barcode: 'HIGH-2', businessCreatedAt: new Date('2026-10-04') },
|
||||
]
|
||||
|
||||
const sorted = sortLimitPricedArchiveCandidates(rows, new Map([
|
||||
['HIGH', 100],
|
||||
['LOW', 50],
|
||||
['NONE', null],
|
||||
]))
|
||||
|
||||
expect(sorted.map((row) => row.id)).toEqual(['high-new', 'high-old', 'low', 'none'])
|
||||
})
|
||||
|
||||
it('field permission limits keyword matches to visible fields', () => {
|
||||
const where = buildProductArchiveWhere(stripProductArchiveBlockedFilters(
|
||||
{ keyword: '机密供应商' }, new Set(['supplier', 'sku']),
|
||||
|
||||
@ -14,7 +14,7 @@ import { FILTER_OPTION_DATABASE_FIELDS } from './supply-chain-product-archive-fi
|
||||
import {
|
||||
PERFORMANCE_KEYS, findPerformance, listPeriodPerformance, listPagePeriodData, overlayPerformance, overlayPagePerformance,
|
||||
performanceText, requiresPeriodDataset,
|
||||
preparePeriodArchiveQuery, readSelectedArchives, type PerformanceKey,
|
||||
preparePeriodArchiveQuery, readSelectedArchives, type PerformanceKey, type PeriodDataset,
|
||||
} from './supply-chain-product-archive-sales-period.repository'
|
||||
|
||||
export { productArchiveSelect, type ProductArchiveRecord } from './supply-chain-product-archive-select'
|
||||
@ -27,8 +27,40 @@ type OptionsParams = {
|
||||
limit: number
|
||||
filters?: ProductArchiveFilters
|
||||
}
|
||||
|
||||
export type ProductArchiveListSort = 'limitPriceDesc'
|
||||
|
||||
type LimitSortCandidate = {
|
||||
id: string
|
||||
sku: string
|
||||
barcode: string
|
||||
businessCreatedAt: Date
|
||||
}
|
||||
|
||||
export function sortLimitPricedArchiveCandidates<T extends LimitSortCandidate>(
|
||||
rows: T[],
|
||||
ownerPriceByStyle: Map<string, number | null>,
|
||||
) {
|
||||
return [...rows].sort((left, right) => {
|
||||
const leftPrice = ownerPriceByStyle.get(left.sku) ?? null
|
||||
const rightPrice = ownerPriceByStyle.get(right.sku) ?? null
|
||||
if (leftPrice === null && rightPrice !== null) return 1
|
||||
if (leftPrice !== null && rightPrice === null) return -1
|
||||
if (leftPrice !== null && rightPrice !== null && leftPrice !== rightPrice) {
|
||||
return rightPrice - leftPrice
|
||||
}
|
||||
return right.businessCreatedAt.getTime() - left.businessCreatedAt.getTime()
|
||||
|| left.barcode.localeCompare(right.barcode, 'zh-CN', { numeric: true })
|
||||
})
|
||||
}
|
||||
|
||||
export interface SupplyChainProductArchiveRepository {
|
||||
listPage(params: { skip: number; take: number; filters?: ProductArchiveFilters }): Promise<{
|
||||
listPage(params: {
|
||||
skip: number
|
||||
take: number
|
||||
filters?: ProductArchiveFilters
|
||||
sort?: ProductArchiveListSort
|
||||
}): Promise<{
|
||||
records: ProductArchiveRecord[]; total: number; salesPeriod?: ArchiveSalesPeriod
|
||||
}>
|
||||
listFilterOptions(params: OptionsParams): Promise<{ values: string[]; hasMore: boolean; total: number }>
|
||||
@ -44,12 +76,80 @@ function pageOptions(raw: string[], params: OptionsParams) {
|
||||
}
|
||||
|
||||
class SupplyChainProductArchiveRepositoryClass implements SupplyChainProductArchiveRepository {
|
||||
async listPage(params: { skip: number; take: number; filters?: ProductArchiveFilters }) {
|
||||
private async listPageByLimitPrice(
|
||||
params: { skip: number; take: number; filters?: ProductArchiveFilters },
|
||||
query: { where: Prisma.SupplyChainProductArchiveWhereInput; ids: string[] | null },
|
||||
dataset?: PeriodDataset,
|
||||
) {
|
||||
const selected = { id: true, sku: true, barcode: true, businessCreatedAt: true } as const
|
||||
let candidates: LimitSortCandidate[]
|
||||
if (query.ids === null) {
|
||||
candidates = await prisma.supplyChainProductArchive.findMany({
|
||||
where: query.where,
|
||||
select: selected,
|
||||
})
|
||||
} else {
|
||||
candidates = []
|
||||
// Prisma/PostgreSQL 单次 IN 参数不宜过大;分块读取满足复杂筛选后的候选集合。
|
||||
for (let index = 0; index < query.ids.length; index += 10_000) {
|
||||
candidates.push(...await prisma.supplyChainProductArchive.findMany({
|
||||
where: { id: { in: query.ids.slice(index, index + 10_000) } },
|
||||
select: selected,
|
||||
}))
|
||||
}
|
||||
}
|
||||
const styleCodes = [...new Set(candidates.map((row) => row.sku.trim()).filter(Boolean))]
|
||||
const limitRows = styleCodes.length
|
||||
? await prisma.supplyChainStyleLimitPrice.findMany({
|
||||
where: { styleCode: { in: styleCodes } },
|
||||
select: { styleCode: true, ownerLimitPrice: true },
|
||||
})
|
||||
: []
|
||||
const ownerPriceByStyle = new Map(limitRows.map((row) => {
|
||||
const price = row.ownerLimitPrice === null ? null : Number(row.ownerLimitPrice)
|
||||
return [row.styleCode, price !== null && Number.isFinite(price) && price > 0 ? price : null] as const
|
||||
}))
|
||||
const sorted = sortLimitPricedArchiveCandidates(candidates, ownerPriceByStyle)
|
||||
const pageIds = sorted.slice(params.skip, params.skip + params.take).map((row) => row.id)
|
||||
if (!pageIds.length) {
|
||||
return { records: [], total: candidates.length, salesPeriod: dataset?.period }
|
||||
}
|
||||
const pageRecords = await prisma.supplyChainProductArchive.findMany({
|
||||
where: { id: { in: pageIds } },
|
||||
select: productArchiveSelect,
|
||||
})
|
||||
const recordById = new Map(pageRecords.map((record) => [record.id, record]))
|
||||
const orderedRecords = pageIds.flatMap((id) => {
|
||||
const record = recordById.get(id)
|
||||
return record ? [record] : []
|
||||
})
|
||||
if (dataset) {
|
||||
return {
|
||||
records: overlayPerformance(orderedRecords, dataset),
|
||||
total: candidates.length,
|
||||
salesPeriod: dataset.period,
|
||||
}
|
||||
}
|
||||
const pageData = await listPagePeriodData(orderedRecords.map((record) => record.barcode), params.filters ?? {})
|
||||
return {
|
||||
records: overlayPagePerformance(orderedRecords, pageData),
|
||||
total: candidates.length,
|
||||
salesPeriod: pageData.period,
|
||||
}
|
||||
}
|
||||
|
||||
async listPage(params: {
|
||||
skip: number
|
||||
take: number
|
||||
filters?: ProductArchiveFilters
|
||||
sort?: ProductArchiveListSort
|
||||
}) {
|
||||
const filters = params.filters ?? {}
|
||||
// 勾了周期业绩筛选或「产品标签」兜底文案:需要全量周期数据集做逐条码判断(走缓存)。
|
||||
if (requiresPeriodDataset(filters)) {
|
||||
const dataset = await listPeriodPerformance(filters)
|
||||
const query = await preparePeriodArchiveQuery(filters, undefined, dataset)
|
||||
if (params.sort === 'limitPriceDesc') return this.listPageByLimitPrice(params, query, dataset)
|
||||
const where = query.ids === null ? query.where : { id: { in: query.ids.slice(params.skip, params.skip + params.take) } }
|
||||
const [records, total] = await Promise.all([
|
||||
prisma.supplyChainProductArchive.findMany({
|
||||
@ -63,6 +163,7 @@ class SupplyChainProductArchiveRepositoryClass implements SupplyChainProductArch
|
||||
}
|
||||
// 普通翻页/搜索/切周期:只聚合当前页条码,不做全量聚合。
|
||||
const query = await preparePeriodArchiveQuery(filters)
|
||||
if (params.sort === 'limitPriceDesc') return this.listPageByLimitPrice(params, query)
|
||||
const [records, total] = await Promise.all([
|
||||
prisma.supplyChainProductArchive.findMany({
|
||||
where: query.where, select: productArchiveSelect,
|
||||
|
||||
@ -79,6 +79,7 @@ export class SupplyChainProductArchiveService {
|
||||
page: number
|
||||
pageSize: number
|
||||
filters?: ProductArchiveFilters
|
||||
sort?: 'limitPriceDesc'
|
||||
/** 注入「是否有检测报告」(外部检测报告接口按 SPU 匹配,只在限价页取数时开) */
|
||||
withInspectionReport?: boolean
|
||||
}) {
|
||||
@ -88,6 +89,7 @@ export class SupplyChainProductArchiveService {
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
filters: params.filters,
|
||||
sort: params.sort,
|
||||
})
|
||||
let warehouseConfigs: ProductArchiveWarehouseConfigRecord[] = []
|
||||
if (this.warehouseConfigRepository) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user