- 新增检测报告索引服务:GET {素材平台}/api/external/certificate-reports(X-API-Key),只认有报告文件的记录,按 SPU 汇总,进程内缓存 10 分钟
- 列取自 external 接口:限价页数据接口与「导出明细(含图片)」都带上 inspectionReport(有 / 无 / 空)
- 表头筛选接入:候选值「有 / 无」,筛选按报告 SPU 清单(接口未配置时不加条件、候选值为空)
- 配置:MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL/API_KEY,不填复用 MATERIAL_LIBRARY_WHITE_BACKGROUND_API_*;未配置时该列显示为空
- 增补单测(解析/缓存/未配置/接口报错)与文档说明
206 lines
12 KiB
TypeScript
206 lines
12 KiB
TypeScript
import { Prisma } from '@prisma/client'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { materialLevelFromMaterial, storageTypeLabel } from '@/lib/supply-chain-product-archive-values'
|
||
import { inferStorageTypeLabelFromWarehouseName } from '@/lib/supply-chain-product-archive-warehouse-config'
|
||
import { supplyChainProductArchiveWarehouseConfigRepository } from './supply-chain-product-archive-warehouse-config.repository'
|
||
import { productArchiveCertificateReportService } from '@/server/services/product-archive-certificate-report.service'
|
||
import {
|
||
PRODUCT_ARCHIVE_DATA_TAG_LABELS, PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE, PRODUCT_ARCHIVE_FILTER_OPTION_KEYS,
|
||
type ProductArchiveFilterOptionKey, type ProductArchiveFilters,
|
||
} from '@/lib/supply-chain-product-archive'
|
||
import type { ArchiveSalesPeriod } from '@/lib/product-archive-sales-period'
|
||
import { productArchiveSelect, type ProductArchiveRecord } from './supply-chain-product-archive-select'
|
||
import { FILTER_OPTION_DATABASE_FIELDS } from './supply-chain-product-archive-filters'
|
||
import {
|
||
PERFORMANCE_KEYS, findPerformance, listPeriodPerformance, listPagePeriodData, overlayPerformance, overlayPagePerformance,
|
||
performanceText, requiresPeriodDataset,
|
||
preparePeriodArchiveQuery, readSelectedArchives, type PerformanceKey,
|
||
} from './supply-chain-product-archive-sales-period.repository'
|
||
|
||
export { productArchiveSelect, type ProductArchiveRecord } from './supply-chain-product-archive-select'
|
||
export { buildProductArchiveWhere } from './supply-chain-product-archive-filters'
|
||
|
||
type OptionsParams = {
|
||
field: ProductArchiveFilterOptionKey
|
||
search?: string
|
||
offset: number
|
||
limit: number
|
||
filters?: ProductArchiveFilters
|
||
}
|
||
export interface SupplyChainProductArchiveRepository {
|
||
listPage(params: { skip: number; take: number; filters?: ProductArchiveFilters }): Promise<{
|
||
records: ProductArchiveRecord[]; total: number; salesPeriod?: ArchiveSalesPeriod
|
||
}>
|
||
listFilterOptions(params: OptionsParams): Promise<{ values: string[]; hasMore: boolean; total: number }>
|
||
}
|
||
function pageOptions(raw: string[], params: OptionsParams) {
|
||
const search = params.search?.trim().toLowerCase()
|
||
const values = [...new Set(raw.map((value) => value || PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE))]
|
||
.filter((value) => !search || (value === PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE ? '空值' : value).toLowerCase().includes(search))
|
||
.sort((a, b) => a === PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE ? -1 : b === PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE ? 1 : a.localeCompare(b, 'zh-CN', { numeric: true }))
|
||
const offset = Math.max(0, params.offset)
|
||
const take = Math.min(1000, Math.max(1, params.limit))
|
||
return { values: values.slice(offset, offset + take), hasMore: values.length > offset + take, total: values.length }
|
||
}
|
||
|
||
class SupplyChainProductArchiveRepositoryClass implements SupplyChainProductArchiveRepository {
|
||
async listPage(params: { skip: number; take: number; filters?: ProductArchiveFilters }) {
|
||
const filters = params.filters ?? {}
|
||
// 勾了周期业绩筛选或「产品标签」兜底文案:需要全量周期数据集做逐条码判断(走缓存)。
|
||
if (requiresPeriodDataset(filters)) {
|
||
const dataset = await listPeriodPerformance(filters)
|
||
const query = await preparePeriodArchiveQuery(filters, undefined, 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({
|
||
where, select: productArchiveSelect,
|
||
orderBy: [{ businessCreatedAt: 'desc' }, { barcode: 'asc' }],
|
||
skip: query.ids === null ? params.skip : 0, take: params.take,
|
||
}),
|
||
query.ids === null ? prisma.supplyChainProductArchive.count({ where }) : Promise.resolve(query.ids.length),
|
||
])
|
||
return { records: overlayPerformance(records, dataset), total, salesPeriod: dataset.period }
|
||
}
|
||
// 普通翻页/搜索/切周期:只聚合当前页条码,不做全量聚合。
|
||
const query = await preparePeriodArchiveQuery(filters)
|
||
const [records, total] = await Promise.all([
|
||
prisma.supplyChainProductArchive.findMany({
|
||
where: query.where, select: productArchiveSelect,
|
||
orderBy: [{ businessCreatedAt: 'desc' }, { barcode: 'asc' }],
|
||
skip: params.skip, take: params.take,
|
||
}),
|
||
prisma.supplyChainProductArchive.count({ where: query.where }),
|
||
])
|
||
const pageData = await listPagePeriodData(records.map((record) => record.barcode), filters)
|
||
return { records: overlayPagePerformance(records, pageData), total, salesPeriod: pageData.period }
|
||
}
|
||
|
||
async listFilterOptions(params: OptionsParams) {
|
||
if (!(PRODUCT_ARCHIVE_FILTER_OPTION_KEYS as readonly string[]).includes(params.field)) {
|
||
return { values: [], hasMore: false, total: 0 }
|
||
}
|
||
if ((PERFORMANCE_KEYS as readonly string[]).includes(params.field)) {
|
||
const dataset = await listPeriodPerformance(params.filters)
|
||
const query = await preparePeriodArchiveQuery(params.filters, params.field, dataset)
|
||
const records = await readSelectedArchives(query, { barcode: true })
|
||
return pageOptions(records.map((row) => performanceText(findPerformance(dataset, row.barcode), params.field as PerformanceKey)), params)
|
||
}
|
||
// 「产品标签」是计算列:候选项 = 当前数据里的 SABC 标签码 + 三个未打标兜底文案。
|
||
if (params.field === 'dataTag') {
|
||
const query = await preparePeriodArchiveQuery(params.filters, 'dataTag')
|
||
if (query.ids === null && Object.keys(query.where).length === 0) {
|
||
const codes = await listDistinctColumnValues('sabcRating', '')
|
||
return pageOptions([
|
||
...codes.filter((value) => value && value.trim()).sort(),
|
||
...Object.values(PRODUCT_ARCHIVE_DATA_TAG_LABELS),
|
||
], params)
|
||
}
|
||
const records = await readSelectedArchives(query, { sabcRating: true } as Prisma.SupplyChainProductArchiveSelect)
|
||
const codes = [...new Set(records.map((row) => row.sabcRating).filter((value) => value && value.trim()))]
|
||
return pageOptions([...codes.sort(), ...Object.values(PRODUCT_ARCHIVE_DATA_TAG_LABELS)], params)
|
||
}
|
||
// 「入库/代发」:档案原始列大多为空,展示值按「仓库配置 > 仓库名推导 > 原始列」得到,
|
||
// 候选值也必须按同一口径算,否则下拉里几乎什么都没有。
|
||
if (params.field === 'storageType') {
|
||
const query = await preparePeriodArchiveQuery(params.filters, params.field)
|
||
const records = await readSelectedArchives(query, { storageType: true, inOutWarehouse: true })
|
||
const configs = await supplyChainProductArchiveWarehouseConfigRepository.listActive().catch(() => [])
|
||
const configByWarehouse = new Map(
|
||
configs.map((config) => [config.warehouseName.trim(), config.storageType] as const),
|
||
)
|
||
const values = records.map((record) => {
|
||
const configured = configByWarehouse.get(String(record.inOutWarehouse ?? '').trim())
|
||
if (configured === 'INBOUND') return '入仓'
|
||
if (configured === 'DROPSHIP') return '代发'
|
||
return storageTypeLabel(record.storageType) || inferStorageTypeLabelFromWarehouseName(record.inOutWarehouse)
|
||
})
|
||
return pageOptions(values, params)
|
||
}
|
||
// 「材质等级」:展示值是从「材质」文本里识别 E0 / E1 / ENF,
|
||
// 候选值按同样口径算(识别不到的归到「空值」)。
|
||
if (params.field === 'materialLevel') {
|
||
const query = await preparePeriodArchiveQuery(params.filters, params.field)
|
||
const records = await readSelectedArchives(query, { material: true })
|
||
const values = records.map((record) => materialLevelFromMaterial(record.material))
|
||
return pageOptions(values, params)
|
||
}
|
||
// 「是否有检测报告」:外部接口按 SPU 匹配,候选项固定「有 / 无」
|
||
// (接口未配置/读取失败时返回空列表,界面显示为空)。
|
||
if (params.field === 'inspectionReport') {
|
||
const index = await productArchiveCertificateReportService.getIndex()
|
||
if (!index) return { values: [], hasMore: false, total: 0 }
|
||
const values = index.spuSet.size > 0 ? ['有', '无'] : ['无']
|
||
return pageOptions(values, params)
|
||
}
|
||
const field = FILTER_OPTION_DATABASE_FIELDS[params.field as keyof typeof FILTER_OPTION_DATABASE_FIELDS]
|
||
if (!field) return { values: [], hasMore: false, total: 0 }
|
||
const query = await preparePeriodArchiveQuery(params.filters, params.field)
|
||
// 无其他筛选时走 SQL 侧 DISTINCT + 搜索下推,不再把全表取值拉进内存去重。
|
||
if (query.ids === null && Object.keys(query.where).length === 0) {
|
||
return listColumnOptionsFromSql(field, params, params.field === 'storageType')
|
||
}
|
||
const records = await readSelectedArchives(query, { [field]: true } as Prisma.SupplyChainProductArchiveSelect)
|
||
const values = records.map((record) => {
|
||
const raw = (record as Record<string, unknown>)[field]
|
||
const value = raw === null || raw === undefined ? '' : String(raw).trim()
|
||
return value
|
||
})
|
||
return pageOptions(values, params)
|
||
}
|
||
}
|
||
|
||
/** SQL 侧取单列去重值(NULL 归并为空串)。仅供候选项接口的无筛选快路径使用。 */
|
||
async function listDistinctColumnValues(column: string, search: string, take?: number, offset = 0) {
|
||
const col = Prisma.raw(`"${column}"`)
|
||
const filter = search
|
||
? Prisma.sql` WHERE ${col} ILIKE ${'%' + search + '%'}`
|
||
: Prisma.empty
|
||
const paging = take === undefined ? Prisma.empty : Prisma.sql` LIMIT ${take} OFFSET ${offset}`
|
||
const rows = await prisma.$queryRaw<Array<{ value: string }>>(Prisma.sql`
|
||
SELECT DISTINCT COALESCE(${col}, '') AS value
|
||
FROM "SupplyChainProductArchive"${filter}
|
||
ORDER BY 1${paging}
|
||
`)
|
||
return rows.map((row) => row.value)
|
||
}
|
||
|
||
async function countDistinctColumnValues(column: string, search: string) {
|
||
const col = Prisma.raw(`"${column}"`)
|
||
const filter = search
|
||
? Prisma.sql` WHERE ${col} ILIKE ${'%' + search + '%'}`
|
||
: Prisma.empty
|
||
const [row] = await prisma.$queryRaw<Array<{ count: bigint }>>(Prisma.sql`
|
||
SELECT COUNT(*) AS count FROM (SELECT DISTINCT COALESCE(${col}, '') AS value FROM "SupplyChainProductArchive"${filter}) c
|
||
`)
|
||
return Number(row?.count ?? 0)
|
||
}
|
||
|
||
/** 候选项 SQL 化:DISTINCT + 搜索下推 + 分页。基数小的列一次取全(保持既有排序口径),大列按页取。 */
|
||
const OPTIONS_FULL_FETCH_LIMIT = 2000
|
||
|
||
async function listColumnOptionsFromSql(field: string, params: OptionsParams, isStorageType: boolean) {
|
||
const search = params.search?.trim() ?? ''
|
||
const total = await countDistinctColumnValues(field, search)
|
||
const format = (value: string) => {
|
||
const mapped = isStorageType && value ? storageTypeLabel(value) : value
|
||
return mapped === '' ? PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE : mapped
|
||
}
|
||
const sortLikePageOptions = (values: string[]) => values.sort((a, b) =>
|
||
a === PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE ? -1
|
||
: b === PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE ? 1
|
||
: a.localeCompare(b, 'zh-CN', { numeric: true }))
|
||
if (total <= OPTIONS_FULL_FETCH_LIMIT) {
|
||
return pageOptions((await listDistinctColumnValues(field, search)).map(format), params)
|
||
}
|
||
const offset = Math.max(0, params.offset)
|
||
const take = Math.min(1000, Math.max(1, params.limit))
|
||
const page = (await listDistinctColumnValues(field, search, take + 1, offset)).map(format)
|
||
const visible = sortLikePageOptions(page.slice(0, take))
|
||
// 与旧口径对齐:搜索词命中「空值」二字时空值项可见。
|
||
if (search && !visible.includes(PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE) && '空值'.includes(search)) {
|
||
visible.unshift(PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE)
|
||
}
|
||
return { values: visible, hasMore: page.length > take, total }
|
||
}
|
||
export const supplyChainProductArchiveRepository = new SupplyChainProductArchiveRepositoryClass()
|