feat(supply-chain): 限价列表按负责人限价降序
This commit is contained in:
parent
ef4e4e36ed
commit
d5b5a60141
@ -13,6 +13,7 @@ import {
|
|||||||
const limitProductsQuerySchema = z.object({
|
const limitProductsQuerySchema = z.object({
|
||||||
page: z.coerce.number().int().min(1).default(1),
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
pageSize: z.coerce.number().int().min(1).max(200).default(50),
|
pageSize: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
|
sort: z.enum(['limitPriceDesc']).default('limitPriceDesc'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@ -22,6 +23,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const parsed = limitProductsQuerySchema.safeParse({
|
const parsed = limitProductsQuerySchema.safeParse({
|
||||||
page: request.nextUrl.searchParams.get('page') ?? undefined,
|
page: request.nextUrl.searchParams.get('page') ?? undefined,
|
||||||
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
|
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
|
||||||
|
sort: request.nextUrl.searchParams.get('sort') ?? undefined,
|
||||||
})
|
})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分页参数不正确', 400, ctx.requestId)
|
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分页参数不正确', 400, ctx.requestId)
|
||||||
@ -53,6 +55,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const parsed = limitProductsQuerySchema.safeParse({
|
const parsed = limitProductsQuerySchema.safeParse({
|
||||||
page: request.nextUrl.searchParams.get('page') ?? undefined,
|
page: request.nextUrl.searchParams.get('page') ?? undefined,
|
||||||
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
|
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
|
||||||
|
sort: request.nextUrl.searchParams.get('sort') ?? undefined,
|
||||||
})
|
})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分页参数不正确', 400, ctx.requestId)
|
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 [productKind, setProductKind] = useState<ProductKind>('bulk')
|
||||||
const [filters, setFilters] = useState<ProductArchiveFilters>(EMPTY_FILTERS)
|
const [filters, setFilters] = useState<ProductArchiveFilters>(EMPTY_FILTERS)
|
||||||
const [comboFilters, setComboFilters] = useState<ComboLimitFilters>({})
|
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 [liveWidths, setLiveWidths] = useState<Record<string, number> | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
@ -163,7 +166,10 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
|
|||||||
const [brandPartitionSettled, setBrandPartitionSettled] = useState(false)
|
const [brandPartitionSettled, setBrandPartitionSettled] = useState(false)
|
||||||
const [imagePreview, setImagePreview] = useState<{ image: string; title: string } | null>(null)
|
const [imagePreview, setImagePreview] = useState<{ image: string; title: string } | null>(null)
|
||||||
const [openFilterKey, setOpenFilterKey] = useState<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)
|
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 { 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 { stripProductArchiveBlockedFilters } from '@/lib/product-archive-field-permissions'
|
||||||
import { PRODUCT_ARCHIVE_KEYWORD_FIELDS } from '@/lib/supply-chain-product-archive'
|
import { PRODUCT_ARCHIVE_KEYWORD_FIELDS } from '@/lib/supply-chain-product-archive'
|
||||||
import { parseProductArchiveFiltersPayload } from '@/server/services/supply-chain-product-archive-query'
|
import { parseProductArchiveFiltersPayload } from '@/server/services/supply-chain-product-archive-query'
|
||||||
import { PRODUCT_ARCHIVE_IMAGE_SEARCH_ELIGIBLE_WHERE } from '../product-archive-image-search.repository'
|
import { PRODUCT_ARCHIVE_IMAGE_SEARCH_ELIGIBLE_WHERE } from '../product-archive-image-search.repository'
|
||||||
|
|
||||||
describe('supply-chain product archive query filters', () => {
|
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', () => {
|
it('field permission limits keyword matches to visible fields', () => {
|
||||||
const where = buildProductArchiveWhere(stripProductArchiveBlockedFilters(
|
const where = buildProductArchiveWhere(stripProductArchiveBlockedFilters(
|
||||||
{ keyword: '机密供应商' }, new Set(['supplier', 'sku']),
|
{ keyword: '机密供应商' }, new Set(['supplier', 'sku']),
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { FILTER_OPTION_DATABASE_FIELDS } from './supply-chain-product-archive-fi
|
|||||||
import {
|
import {
|
||||||
PERFORMANCE_KEYS, findPerformance, listPeriodPerformance, listPagePeriodData, overlayPerformance, overlayPagePerformance,
|
PERFORMANCE_KEYS, findPerformance, listPeriodPerformance, listPagePeriodData, overlayPerformance, overlayPagePerformance,
|
||||||
performanceText, requiresPeriodDataset,
|
performanceText, requiresPeriodDataset,
|
||||||
preparePeriodArchiveQuery, readSelectedArchives, type PerformanceKey,
|
preparePeriodArchiveQuery, readSelectedArchives, type PerformanceKey, type PeriodDataset,
|
||||||
} from './supply-chain-product-archive-sales-period.repository'
|
} from './supply-chain-product-archive-sales-period.repository'
|
||||||
|
|
||||||
export { productArchiveSelect, type ProductArchiveRecord } from './supply-chain-product-archive-select'
|
export { productArchiveSelect, type ProductArchiveRecord } from './supply-chain-product-archive-select'
|
||||||
@ -27,8 +27,40 @@ type OptionsParams = {
|
|||||||
limit: number
|
limit: number
|
||||||
filters?: ProductArchiveFilters
|
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 {
|
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
|
records: ProductArchiveRecord[]; total: number; salesPeriod?: ArchiveSalesPeriod
|
||||||
}>
|
}>
|
||||||
listFilterOptions(params: OptionsParams): Promise<{ values: string[]; hasMore: boolean; total: number }>
|
listFilterOptions(params: OptionsParams): Promise<{ values: string[]; hasMore: boolean; total: number }>
|
||||||
@ -44,12 +76,80 @@ function pageOptions(raw: string[], params: OptionsParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SupplyChainProductArchiveRepositoryClass implements SupplyChainProductArchiveRepository {
|
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 ?? {}
|
const filters = params.filters ?? {}
|
||||||
// 勾了周期业绩筛选或「产品标签」兜底文案:需要全量周期数据集做逐条码判断(走缓存)。
|
// 勾了周期业绩筛选或「产品标签」兜底文案:需要全量周期数据集做逐条码判断(走缓存)。
|
||||||
if (requiresPeriodDataset(filters)) {
|
if (requiresPeriodDataset(filters)) {
|
||||||
const dataset = await listPeriodPerformance(filters)
|
const dataset = await listPeriodPerformance(filters)
|
||||||
const query = await preparePeriodArchiveQuery(filters, undefined, dataset)
|
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 where = query.ids === null ? query.where : { id: { in: query.ids.slice(params.skip, params.skip + params.take) } }
|
||||||
const [records, total] = await Promise.all([
|
const [records, total] = await Promise.all([
|
||||||
prisma.supplyChainProductArchive.findMany({
|
prisma.supplyChainProductArchive.findMany({
|
||||||
@ -63,6 +163,7 @@ class SupplyChainProductArchiveRepositoryClass implements SupplyChainProductArch
|
|||||||
}
|
}
|
||||||
// 普通翻页/搜索/切周期:只聚合当前页条码,不做全量聚合。
|
// 普通翻页/搜索/切周期:只聚合当前页条码,不做全量聚合。
|
||||||
const query = await preparePeriodArchiveQuery(filters)
|
const query = await preparePeriodArchiveQuery(filters)
|
||||||
|
if (params.sort === 'limitPriceDesc') return this.listPageByLimitPrice(params, query)
|
||||||
const [records, total] = await Promise.all([
|
const [records, total] = await Promise.all([
|
||||||
prisma.supplyChainProductArchive.findMany({
|
prisma.supplyChainProductArchive.findMany({
|
||||||
where: query.where, select: productArchiveSelect,
|
where: query.where, select: productArchiveSelect,
|
||||||
|
|||||||
@ -79,6 +79,7 @@ export class SupplyChainProductArchiveService {
|
|||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
filters?: ProductArchiveFilters
|
filters?: ProductArchiveFilters
|
||||||
|
sort?: 'limitPriceDesc'
|
||||||
/** 注入「是否有检测报告」(外部检测报告接口按 SPU 匹配,只在限价页取数时开) */
|
/** 注入「是否有检测报告」(外部检测报告接口按 SPU 匹配,只在限价页取数时开) */
|
||||||
withInspectionReport?: boolean
|
withInspectionReport?: boolean
|
||||||
}) {
|
}) {
|
||||||
@ -88,6 +89,7 @@ export class SupplyChainProductArchiveService {
|
|||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
filters: params.filters,
|
filters: params.filters,
|
||||||
|
sort: params.sort,
|
||||||
})
|
})
|
||||||
let warehouseConfigs: ProductArchiveWarehouseConfigRecord[] = []
|
let warehouseConfigs: ProductArchiveWarehouseConfigRecord[] = []
|
||||||
if (this.warehouseConfigRepository) {
|
if (this.warehouseConfigRepository) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user