feat(supply-chain): 限价列表按负责人限价降序

This commit is contained in:
居居 2026-10-19 15:37:05 +08:00
parent 793e72bd27
commit ea9851fd98
5 changed files with 138 additions and 6 deletions

View File

@ -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)

View File

@ -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)
// 大货/品牌两个子界面的划分依据:品牌字段(含顾家/索菲亚/朴美 → 品牌;其余 → 大货)

View File

@ -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']),

View File

@ -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,

View File

@ -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) {