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_*;未配置时该列显示为空
- 增补单测(解析/缓存/未配置/接口报错)与文档说明
This commit is contained in:
居居 2026-10-16 17:55:03 +08:00 committed by WuYongJie
parent eca1d63842
commit 2fde21f5da
14 changed files with 327 additions and 6 deletions

View File

@ -176,6 +176,9 @@ ALIBABA_1688_IMAGE_SEARCH_TAGS=4306497
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_ENABLED=false
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_BASE_URL=https://example.com
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_KEY=replace-with-material-library-api-key
# 检测报告查询(不填则复用上面素材平台的 BaseUrl / API Key;都没有时「是否有检测报告」显示为空)
MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL=
MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_KEY=
# 产品全量档案以图搜图(模型文件放在仓库外,不提交到 Git)
IMAGE_SEARCH_MODEL_PATH=

View File

@ -20,7 +20,10 @@
9. **负责人触发规则**:非运营发起的流程,前两个月全公司运营同价(不归属个人);两个月后按销量触发负责人(销量高者为负责人)。
10. **数据快照**:破价单在生成时保存金额快照;退款/售后状态单独记录,退款不等于免责(仍需判断是否已调价)。
11. **离职人员**:订单负责人已离职 → 自动标记「人员已离职」,不判定、不计乐捐。
12. **手工换货单**:聚水潭订单明细出现「被替换行(`item_status=Replaced`、数量归 0)」+「换入行(`item_ext_data` 含 `item_source=手工换货/手工添加`)」时按换货单处理:
12. **是否有检测报告**:取素材平台「检测报告」接口(`/api/external/certificate-reports`,按 **SPU** 精确匹配,只认有报告文件的记录),进程内缓存 10 分钟;
接口未配置或读取失败时该列显示为空、筛选候选值为空。配置项:`MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL/API_KEY`,
不填则复用素材平台 `MATERIAL_LIBRARY_WHITE_BACKGROUND_API_*`。
13. **手工换货单**:聚水潭订单明细出现「被替换行(`item_status=Replaced`、数量归 0)」+「换入行(`item_ext_data` 含 `item_source=手工换货/手工添加`)」时按换货单处理:
- 判定按**换出行原编码**的限价执行(客户在平台上购买的那个编码);
- 成交数量与商家实收取**换入行**(换货不改变订单成交金额);换入行金额抓不到时回退订单主表商家实收 → 订单主表买家实付,都取不到才按 0(由运营申诉说明);
- 换货行配对成功后按原编码生成一条,不再按换入编码单独生成,避免同一订单重复生成两条破价单;

View File

@ -35,6 +35,8 @@ export async function GET(request: NextRequest) {
const result = await supplyChainProductArchiveService.listPage({
...parsed.data,
filters: filters.data,
// 限价页需要「是否有检测报告」(外部检测报告接口按 SPU 匹配)
withInspectionReport: true,
})
return jsonSuccess(result, 200, ctx.requestId)
},
@ -64,6 +66,7 @@ export async function POST(request: NextRequest) {
const result = await supplyChainProductArchiveService.listPage({
...parsed.data,
filters: filters.data,
withInspectionReport: true,
})
return jsonSuccess(result, 200, ctx.requestId)
},

View File

@ -15,6 +15,7 @@ function limitTableColumnHint(column: LimitTableColumn) {
if (column.format === 'packSize') return '由产品全量档案的长、宽、高组合'
if (column.format === 'assemblyType') return '由商品标签(整装/组装)推导'
if (column.format === 'materialLevel') return '从「材质」字段识别 E0 / E1 / ENF,识别不到为空'
if (column.key === 'inspectionReport') return '来自素材平台检测报告接口(按 SPU 匹配,取不到时为空)'
if (column.key === 'dataTag') return '取商品SABC打标结果(S/A/B/C/D)'
if (column.key === 'ownerLimitPrice') return '取产品全量档案的运营负责人限价(当前暂未同步数据)'
return '数据来源:产品全量档案'

View File

@ -59,7 +59,8 @@ export const LIMIT_TABLE_COLUMNS: LimitTableColumn[] = [
{ key: 'brand', label: '品牌', group: 'basic', width: 136, kind: 'archive', prop: 'brand', filterKey: 'brand' },
{ key: 'productName', label: '商品名称', group: 'basic', width: 190, kind: 'archive', prop: 'productName', filterKey: 'productName' },
{ key: 'createTime', label: '商品编码创建时间', group: 'basic', width: 150, kind: 'archive', prop: 'createTime', format: 'dateTime' },
{ key: 'inspectionReport', label: '是否有检测报告', group: 'basic', width: 128, kind: 'placeholder' },
// 是否有检测报告:外部「检测报告」接口按 SPU 匹配('有' / '无',读取不到为空)
{ key: 'inspectionReport', label: '是否有检测报告', group: 'basic', width: 128, kind: 'archive', prop: 'inspectionReport', filterKey: 'inspectionReport' },
{ key: 'storageType', label: '入库/代发', group: 'basic', width: 100, kind: 'archive', prop: 'storageType', filterKey: 'storageType' },
{ key: 'productTag', label: '商品标签', group: 'basic', width: 140, kind: 'archive', prop: 'productTag', filterKey: 'productTag' },
{ key: 'assemblyType', label: '整装/组装', group: 'basic', width: 104, kind: 'archive', format: 'assemblyType' },

View File

@ -38,6 +38,8 @@ export type SupplyChainProductArchiveRow = {
installDocId: string
hasDrawing: string
hasVideo: string
/** 是否有检测报告(对外接口按 SPU 匹配,'有' / '无' / '' 表示读取不到) */
inspectionReport: string
spec: string
brand: string
orderType: string
@ -296,7 +298,7 @@ export type ProductArchiveFilters = {
export const PRODUCT_ARCHIVE_FILTER_OPTION_KEYS = [
'image', 'categoryL1', 'categoryL2', 'spu', 'sku', 'barcode', 'productName', 'designer',
'productStatus', 'productionType', 'material', 'materialLevel', 'color', 'spec', 'weight', 'length', 'width',
'productStatus', 'productionType', 'material', 'materialLevel', 'inspectionReport', 'color', 'spec', 'weight', 'length', 'width',
'height', 'volume',
'packType', 'productTag', 'brand', 'orderType', 'storageType', 'isSeasonal', 'replaceCount',
'region', 'supplier', 'supplierType', 'supplierStatus', 'purchaser',

View File

@ -1,6 +1,7 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { supplyChainProductArchiveWarehouseConfigRepository } from '@/server/repositories/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,
@ -137,6 +138,31 @@ function buildEmptyMaterialLevelClause(): Prisma.SupplyChainProductArchiveWhereI
}
}
/**
* 「是否有检测报告」筛选:数据来自外部检测报告接口(按 SPU 匹配,10 分钟缓存)。
* 有 → SPU 在报告清单里;无 → SPU 不在(含空 SPU)。
* 接口未配置/读取失败时返回 null,调用方跳过该条件(页面显示为空)。
*/
async function buildInspectionReportClause(
values: string[],
): Promise<Prisma.SupplyChainProductArchiveWhereInput | null> {
const index = await productArchiveCertificateReportService.getIndex()
if (!index) return null
const wanted = new Set(values.map((value) => value.trim()))
const clauses: Prisma.SupplyChainProductArchiveWhereInput[] = []
const spuList = [...index.spuSet]
if (wanted.has('有')) {
clauses.push(spuList.length > 0 ? { spu: { in: spuList, mode: 'insensitive' } } : { spu: '___none___' })
}
if (wanted.has('无')) {
clauses.push(spuList.length > 0
? { NOT: { spu: { in: spuList, mode: 'insensitive' } } }
: {})
}
if (clauses.length === 0) return null
return clauses.length === 1 ? clauses[0] : { OR: clauses }
}
function buildEmptyValueClause(databaseField: string): Prisma.SupplyChainProductArchiveWhereInput {
if (NULLABLE_PRODUCT_ARCHIVE_FILTER_COLUMNS.has(databaseField)) {
return { OR: [{ [databaseField]: '' }, { [databaseField]: null }] } as Prisma.SupplyChainProductArchiveWhereInput
@ -211,7 +237,7 @@ export function buildProductArchiveWhere(
if (filterKey === omitFilter) continue
// 「入库/代发」「材质等级」不是档案原始列的直接映射(分别是仓库名推导 / 材质文本识别),
// 由 buildProductArchiveWhereWithTextRules 补条件,这里跳过。
if (filterKey === 'storageType' || filterKey === 'materialLevel') continue
if (filterKey === 'storageType' || filterKey === 'materialLevel' || filterKey === 'inspectionReport') continue
const values = filters[filterKey as ProductArchiveFilterOptionKey]
if (!values?.length) continue
const includesEmpty = values.includes(PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE)
@ -523,6 +549,15 @@ export async function buildProductArchiveWhereWithTextRules(
if (clauses.length > 0) extra.push(clauses.length === 1 ? clauses[0] : { OR: clauses })
}
}
// 「是否有检测报告」:按外部检测报告接口的 SPU 清单筛选。
if (omitFilter !== 'inspectionReport') {
const reportValues = (filters.inspectionReport ?? [])
.filter((value) => value !== PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE)
if (reportValues.length > 0) {
const clause = await buildInspectionReportClause(reportValues)
if (clause) extra.push(clause)
}
}
const asyncClauses = [...extra, ...ownerClauses]
return asyncClauses.length > 0 ? { AND: [base, ...asyncClauses] } : base
}

View File

@ -3,6 +3,7 @@ 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,
@ -123,6 +124,14 @@ class SupplyChainProductArchiveRepositoryClass implements SupplyChainProductArch
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)

View File

@ -0,0 +1,105 @@
import {
ProductArchiveCertificateReportService,
readCertificateReportSpus,
} from '../product-archive-certificate-report.service'
describe('检测报告索引(外部接口)', () => {
const originalEnv = { ...process.env }
const originalFetch = global.fetch
afterEach(() => {
process.env = { ...originalEnv }
global.fetch = originalFetch
jest.restoreAllMocks()
})
it('只把「有报告文件」的记录算作有检测报告,SPU 统一大写', () => {
const spuSet = new Set<string>()
const count = readCertificateReportSpus({
success: true,
data: {
items: [
{ spu: 'gbsfy-sj0380', files: [{ fileKind: 'pdf' }] },
{ spu: 'SJ0381', files: [] },
{ spu: null, files: [{ fileKind: 'image' }] },
{ spu: 'GBGJ-SJ0382', files: [{ fileKind: 'pdf' }, { fileKind: 'image' }] },
],
pagination: { hasMore: false },
},
}, spuSet)
expect(count).toBe(4)
expect([...spuSet].sort()).toEqual(['GBGJ-SJ0382', 'GBSFY-SJ0380'])
})
it('未配置 BaseUrl / API Key 时不请求接口并返回 null', async () => {
delete process.env.MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL
delete process.env.MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_KEY
delete process.env.MATERIAL_LIBRARY_WHITE_BACKGROUND_API_BASE_URL
delete process.env.MATERIAL_LIBRARY_WHITE_BACKGROUND_API_KEY
const fetchMock = jest.fn()
global.fetch = fetchMock as unknown as typeof fetch
const service = new ProductArchiveCertificateReportService()
expect(service.isConfigured()).toBe(false)
expect(await service.getIndex()).toBeNull()
expect(await service.hasReportForSpu('SJ0380')).toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})
it('翻页拉全量并按 SPU 命中,结果进程内缓存', async () => {
process.env.MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL = 'https://sc.example.com'
process.env.MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_KEY = 'test-key'
const fetchMock = jest.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
success: true,
data: {
items: [{ spu: 'SJ0380', files: [{ fileKind: 'pdf' }] }],
pagination: { hasMore: true },
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
success: true,
data: {
items: [{ spu: 'SJ0381', files: [{ fileKind: 'pdf' }] }],
pagination: { hasMore: false },
},
}),
})
global.fetch = fetchMock as unknown as typeof fetch
const service = new ProductArchiveCertificateReportService()
const index = await service.getIndex()
expect(index?.spuSet.has('SJ0380')).toBe(true)
expect(index?.spuSet.has('SJ0381')).toBe(true)
expect(index?.reportCount).toBe(2)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(String(fetchMock.mock.calls[0][0])).toContain('/api/external/certificate-reports')
expect((fetchMock.mock.calls[0][1] as RequestInit).headers).toEqual({ 'X-API-Key': 'test-key' })
expect(await service.hasReportForSpu('sj0380')).toBe(true)
expect(await service.hasReportForSpu('SJ9999')).toBe(false)
// 命中缓存,不再请求
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('接口报错时保留上一次成功索引(无缓存则返回 null)', async () => {
process.env.MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL = 'https://sc.example.com'
process.env.MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_KEY = 'test-key'
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({}),
}) as unknown as typeof fetch
const service = new ProductArchiveCertificateReportService()
expect(await service.getIndex()).toBeNull()
})
})

View File

@ -0,0 +1,133 @@
/**
* 检测报告索引:调用素材平台「检测报告」接口,汇总「哪些 SPU 有检测报告」。
*
* 接口口径见《外部接口文档》→ 检测报告:组合查询与逐文件下载
* GET {素材平台}/api/external/certificate-reports,请求头 X-API-Key
* 说明:平台按 SPU 匹配(没有 SKU / 条码条件),所以这里只维护 SPU 维度;
* 报告里没有合规文件(files 为空)时不算「有报告」。
*
* 配置(两项都取不到就不请求,界面显示为空):
* MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL ?? MATERIAL_LIBRARY_WHITE_BACKGROUND_API_BASE_URL
* MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_KEY ?? MATERIAL_LIBRARY_WHITE_BACKGROUND_API_KEY
*/
const CACHE_TTL_MS = 10 * 60 * 1000
const REQUEST_TIMEOUT_MS = 30_000
const PAGE_SIZE = 50
/** 最多翻 200 页(≈1 万份报告),防止接口异常时无限翻页。 */
const MAX_PAGES = 200
export type ProductArchiveCertificateReportIndex = {
/** 有检测报告文件的 SPU(统一大写去空格)。 */
spuSet: Set<string>
/** 本次索引覆盖的报告条数。 */
reportCount: number
fetchedAt: number
}
function trimToNull(value: string | undefined) {
const text = String(value ?? '').trim()
return text ? text : null
}
function resolveConnection(): { baseUrl: string; apiKey: string } | null {
const baseUrl = trimToNull(process.env.MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL)
?? trimToNull(process.env.MATERIAL_LIBRARY_WHITE_BACKGROUND_API_BASE_URL)
const apiKey = trimToNull(process.env.MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_KEY)
?? trimToNull(process.env.MATERIAL_LIBRARY_WHITE_BACKGROUND_API_KEY)
if (!baseUrl || !apiKey) return null
return { baseUrl, apiKey }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** 从接口返回里取出「有报告文件的 SPU」。 */
export function readCertificateReportSpus(payload: unknown, spuSet: Set<string>) {
if (!isRecord(payload) || !isRecord(payload.data) || !Array.isArray(payload.data.items)) return 0
let counted = 0
for (const item of payload.data.items) {
if (!isRecord(item)) continue
counted += 1
const files = Array.isArray(item.files) ? item.files : []
if (files.length === 0) continue
const spu = typeof item.spu === 'string' ? item.spu.trim().toUpperCase() : ''
if (spu) spuSet.add(spu)
}
return counted
}
function readHasMore(payload: unknown) {
if (!isRecord(payload) || !isRecord(payload.data) || !isRecord(payload.data.pagination)) return false
return payload.data.pagination.hasMore === true
}
/** 导出 class 以便单测注入 fetch / 环境变量。 */
export class ProductArchiveCertificateReportService {
private cache: ProductArchiveCertificateReportIndex | null = null
private inFlight: Promise<ProductArchiveCertificateReportIndex | null> | null = null
isConfigured() {
return resolveConnection() !== null
}
/** 当前进程内的报告索引(10 分钟缓存);未配置或读取失败返回 null。 */
async getIndex(): Promise<ProductArchiveCertificateReportIndex | null> {
const cached = this.cache
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) return cached
if (this.inFlight) return this.inFlight
this.inFlight = this.load().finally(() => { this.inFlight = null })
return this.inFlight
}
/** 某个 SPU 是否有检测报告;索引不可用时返回 null(界面显示为空)。 */
async hasReportForSpu(spu: string | null | undefined): Promise<boolean | null> {
const normalized = String(spu ?? '').trim().toUpperCase()
const index = await this.getIndex()
if (!index) return null
if (!normalized) return false
return index.spuSet.has(normalized)
}
private async load(): Promise<ProductArchiveCertificateReportIndex | null> {
const connection = resolveConnection()
if (!connection) return null
const spuSet = new Set<string>()
let reportCount = 0
try {
for (let page = 1; page <= MAX_PAGES; page += 1) {
const url = new URL('api/external/certificate-reports', `${connection.baseUrl.replace(/\/+$/, '')}/`)
url.searchParams.set('page', String(page))
url.searchParams.set('pageSize', String(PAGE_SIZE))
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
let payload: unknown = null
try {
const response = await fetch(url, {
headers: { 'X-API-Key': connection.apiKey },
signal: controller.signal,
cache: 'no-store',
})
if (!response.ok) {
throw new Error(`检测报告接口返回 ${response.status}`)
}
payload = await response.json()
} finally {
clearTimeout(timeout)
}
reportCount += readCertificateReportSpus(payload, spuSet)
if (!readHasMore(payload)) break
}
} catch (error) {
console.warn('[certificate-report] 读取检测报告索引失败:', error instanceof Error ? error.message : error)
return this.cache
}
this.cache = { spuSet, reportCount, fetchedAt: Date.now() }
return this.cache
}
}
export const productArchiveCertificateReportService = new ProductArchiveCertificateReportService()

View File

@ -38,6 +38,7 @@ import {
mapProductArchiveRow,
normalizeWarehouseName,
} from './supply-chain-product-archive.mapper'
import { productArchiveCertificateReportService } from './product-archive-certificate-report.service'
/** 导出工作表名。改这里会改变产物的 sheet 名,前端无依赖,但用户会看到。 */
export const PRODUCT_ARCHIVE_EXPORT_SHEET_NAME = '产品档案资料'
@ -162,7 +163,7 @@ export class SupplyChainProductArchiveExportService {
* 人员信息在职状态同样只取一次:导出里运营负责人/设计师列直接写「接管展示值」,
* 与页面同一口径(2026-10-12)。
*/
private async createRowMapper() {
private async createRowMapper(options: { withInspectionReport?: boolean } = {}) {
let warehouseConfigs: ProductArchiveWarehouseConfigRecord[] = []
try {
warehouseConfigs = await this.warehouseConfigRepository.listActive()
@ -198,9 +199,18 @@ export class SupplyChainProductArchiveExportService {
console.warn('[product-archive-export] limit price lookup failed', error)
}
// 检测报告(外部接口,按 SPU):只在导出限价列单时加载,索引进程内缓存 10 分钟
const certificateReportIndex = options.withInspectionReport
? await productArchiveCertificateReportService.getIndex()
: null
const now = this.now()
return (record: ProductArchiveRecord): SupplyChainProductArchiveRow => {
const row = mapProductArchiveRow({ record, now })
const reportSpu = String(record.spu ?? '').trim().toUpperCase()
const inspectionReport = certificateReportIndex
? (reportSpu && certificateReportIndex.spuSet.has(reportSpu) ? '有' : '无')
: ''
const config = warehouseConfigByName.get(normalizeWarehouseName(record.inOutWarehouse))
const warehouseFields = resolveProductArchiveWarehouseFields({
warehouseName: record.inOutWarehouse,
@ -216,6 +226,7 @@ export class SupplyChainProductArchiveExportService {
}
return {
...row,
inspectionReport,
...warehouseFields,
opsHead: resolveOwnerTakeover(record.opsHead ?? '', ownerEmploymentIndex).display || row.opsHead,
designer: resolveDesignerView(record.designer ?? '', ownerEmploymentIndex).display || row.designer,
@ -253,8 +264,8 @@ export class SupplyChainProductArchiveExportService {
const release = acquireExportSlot()
try {
const rowSource = await this.repository.openRowSource(params.filters)
const mapRow = await this.createRowMapper()
const isLimitSet = params.columnSet === 'limit'
const mapRow = await this.createRowMapper({ withInspectionReport: isLimitSet })
const columns = (isLimitSet
? LIMIT_TABLE_COLUMNS.map((column) => ({ prop: column.key, label: column.label, width: column.width }))
: ARCHIVE_COLUMNS.flatMap((group) => group.columns)

View File

@ -41,6 +41,7 @@ const productArchiveFiltersSchema = z.object({
productionType: archiveValueArraySchema.optional(),
material: archiveValueArraySchema.optional(),
materialLevel: archiveValueArraySchema.optional(),
inspectionReport: archiveValueArraySchema.optional(),
color: archiveValueArraySchema.optional(),
brand: archiveValueArraySchema.optional(),
supplier: archiveValueArraySchema.optional(),

View File

@ -109,6 +109,8 @@ export function mapProductArchiveRow({
installDocId: record.installDocId,
hasDrawing: record.hasDrawing,
hasVideo: record.hasVideo,
// 是否有检测报告:由外部「检测报告」接口按 SPU 注入(见 listPage 的 withInspectionReport 选项)
inspectionReport: '',
spec: record.spec,
brand: record.brand,
orderType: record.orderType,

View File

@ -30,6 +30,7 @@ import type { SupplyChainManagementOwnerConfigRepository } from '@/server/reposi
import { supplyChainManagementOwnerConfigRepository } from '@/server/repositories/supply-chain-management-owner-config.repository'
import { prisma } from '@/lib/prisma'
import { resolveLimitPriceFields } from '@/lib/supply-chain-limit-price-fields'
import { productArchiveCertificateReportService } from './product-archive-certificate-report.service'
export function resolveProductArchiveWarehouseFields(input: {
warehouseName: string
@ -78,6 +79,8 @@ export class SupplyChainProductArchiveService {
page: number
pageSize: number
filters?: ProductArchiveFilters
/** 注入「是否有检测报告」(外部检测报告接口按 SPU 匹配,只在限价页取数时开) */
withInspectionReport?: boolean
}) {
const page = Math.max(1, Math.floor(params.page))
const pageSize = Math.min(200, Math.max(1, Math.floor(params.pageSize)))
@ -118,10 +121,18 @@ export class SupplyChainProductArchiveService {
}
}
const now = new Date()
// 检测报告索引:整表按 SPU 缓存 10 分钟;接口未配置/读取失败时返回 null,界面显示为空
const certificateReportIndex = params.withInspectionReport
? await productArchiveCertificateReportService.getIndex()
: null
return {
salesPeriod,
rows: records.map((record) => {
const row = mapProductArchiveRow({ record, now })
const reportSpu = String(record.spu ?? '').trim().toUpperCase()
const inspectionReport = certificateReportIndex
? (reportSpu && certificateReportIndex.spuSet.has(reportSpu) ? '有' : '无')
: ''
const config = warehouseConfigByName.get(normalizeWarehouseName(record.inOutWarehouse))
const warehouseFields = resolveProductArchiveWarehouseFields({
warehouseName: record.inOutWarehouse,
@ -145,6 +156,7 @@ export class SupplyChainProductArchiveService {
const designerView = resolveDesignerView(row.designer ?? '', ownerEmploymentIndex)
return {
...row,
inspectionReport,
...warehouseFields,
...limitFields,
opsHeadDisplay: ownerView.display || row.opsHead,