furniture-3d-system-Wu/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts
居居 2fde21f5da feat(supply-chain): 限价明细「是否有检测报告」接入素材平台检测报告接口
- 新增检测报告索引服务: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_*;未配置时该列显示为空
- 增补单测(解析/缓存/未配置/接口报错)与文档说明
2026-10-19 09:23:04 +08:00

206 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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