diff --git a/furniture-3d-system/src/app/api/supply-chain/limit-management/products/route.ts b/furniture-3d-system/src/app/api/supply-chain/limit-management/products/route.ts index 90175704..9cd06754 100644 --- a/furniture-3d-system/src/app/api/supply-chain/limit-management/products/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/limit-management/products/route.ts @@ -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) diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx index 17fe0734..ba50b8b0 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx @@ -154,7 +154,10 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c const [productKind, setProductKind] = useState('bulk') const [filters, setFilters] = useState(EMPTY_FILTERS) const [comboFilters, setComboFilters] = useState({}) - const [comboSort, setComboSort] = useState(null) + const [comboSort, setComboSort] = useState({ + field: 'ownerLimitPrice', + direction: 'desc', + }) // 列宽拖拽:拖动过程中用本地临时宽度,松开鼠标后一次性持久化到个人设置。 const [liveWidths, setLiveWidths] = useState | 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(null) - const [sort, setSort] = useState(null) + const [sort, setSort] = useState({ + key: 'ownerLimitPrice', + direction: 'desc', + }) const [openSortKey, setOpenSortKey] = useState(null) // 大货/品牌两个子界面的划分依据:品牌字段(含顾家/索菲亚/朴美 → 品牌;其余 → 大货) diff --git a/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive.repository.test.ts b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive.repository.test.ts index 87c93602..0e1821a3 100644 --- a/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive.repository.test.ts +++ b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive.repository.test.ts @@ -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']), diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts index 2e80d2b9..9c20e2ab 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts @@ -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( + rows: T[], + ownerPriceByStyle: Map, +) { + 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, diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts index b09ddec3..4a7b09a6 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts @@ -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) {