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:
parent
eca1d63842
commit
2fde21f5da
@ -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=
|
||||
|
||||
@ -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(由运营申诉说明);
|
||||
- 换货行配对成功后按原编码生成一条,不再按换入编码单独生成,避免同一订单重复生成两条破价单;
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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 '数据来源:产品全量档案'
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user