From 2fde21f5da80eb52178f1dfee80be1c32da9ffb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Fri, 16 Oct 2026 17:55:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(supply-chain):=20=E9=99=90=E4=BB=B7?= =?UTF-8?q?=E6=98=8E=E7=BB=86=E3=80=8C=E6=98=AF=E5=90=A6=E6=9C=89=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E6=8A=A5=E5=91=8A=E3=80=8D=E6=8E=A5=E5=85=A5=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=B9=B3=E5=8F=B0=E6=A3=80=E6=B5=8B=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增检测报告索引服务: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_*;未配置时该列显示为空 - 增补单测(解析/缓存/未配置/接口报错)与文档说明 --- furniture-3d-system/.env.example | 3 + .../docs/限价与破价判定规则.md | 5 +- .../limit-management/products/route.ts | 3 + .../custom-table/LimitTableHeaderCell.tsx | 1 + .../custom-table/limit-table-columns.ts | 3 +- .../src/lib/supply-chain-product-archive.ts | 4 +- .../supply-chain-product-archive-filters.ts | 37 ++++- ...supply-chain-product-archive.repository.ts | 9 ++ ...archive-certificate-report.service.test.ts | 105 ++++++++++++++ ...duct-archive-certificate-report.service.ts | 133 ++++++++++++++++++ ...ly-chain-product-archive-export.service.ts | 15 +- .../supply-chain-product-archive-query.ts | 1 + .../supply-chain-product-archive.mapper.ts | 2 + .../supply-chain-product-archive.service.ts | 12 ++ 14 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 furniture-3d-system/src/server/services/__tests__/product-archive-certificate-report.service.test.ts create mode 100644 furniture-3d-system/src/server/services/product-archive-certificate-report.service.ts diff --git a/furniture-3d-system/.env.example b/furniture-3d-system/.env.example index ad912fd9..db28f242 100644 --- a/furniture-3d-system/.env.example +++ b/furniture-3d-system/.env.example @@ -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= diff --git a/furniture-3d-system/docs/限价与破价判定规则.md b/furniture-3d-system/docs/限价与破价判定规则.md index 515880ec..a57bfdb1 100644 --- a/furniture-3d-system/docs/限价与破价判定规则.md +++ b/furniture-3d-system/docs/限价与破价判定规则.md @@ -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(由运营申诉说明); - 换货行配对成功后按原编码生成一条,不再按换入编码单独生成,避免同一订单重复生成两条破价单; 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 1a47d0f7..90175704 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 @@ -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) }, diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx index 5d63f709..e8836ce5 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx @@ -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 '数据来源:产品全量档案' diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-table-columns.ts b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-table-columns.ts index 59c0c362..a01e6176 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-table-columns.ts +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-table-columns.ts @@ -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' }, diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive.ts b/furniture-3d-system/src/lib/supply-chain-product-archive.ts index 9a6375a5..a8e10e8b 100644 --- a/furniture-3d-system/src/lib/supply-chain-product-archive.ts +++ b/furniture-3d-system/src/lib/supply-chain-product-archive.ts @@ -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', diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts index f031bf62..b8ab00e1 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts @@ -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 { + 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 } 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 e18b2b50..667d60fd 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 @@ -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) diff --git a/furniture-3d-system/src/server/services/__tests__/product-archive-certificate-report.service.test.ts b/furniture-3d-system/src/server/services/__tests__/product-archive-certificate-report.service.test.ts new file mode 100644 index 00000000..d9dd646b --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/product-archive-certificate-report.service.test.ts @@ -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() + 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() + }) +}) diff --git a/furniture-3d-system/src/server/services/product-archive-certificate-report.service.ts b/furniture-3d-system/src/server/services/product-archive-certificate-report.service.ts new file mode 100644 index 00000000..ab4c295b --- /dev/null +++ b/furniture-3d-system/src/server/services/product-archive-certificate-report.service.ts @@ -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 + /** 本次索引覆盖的报告条数。 */ + 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** 从接口返回里取出「有报告文件的 SPU」。 */ +export function readCertificateReportSpus(payload: unknown, spuSet: Set) { + 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 | null = null + + isConfigured() { + return resolveConnection() !== null + } + + /** 当前进程内的报告索引(10 分钟缓存);未配置或读取失败返回 null。 */ + async getIndex(): Promise { + 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 { + 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 { + const connection = resolveConnection() + if (!connection) return null + + const spuSet = new Set() + 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() diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-export.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-export.service.ts index ef1fc961..682d3c4a 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-export.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-export.service.ts @@ -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) diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts index 65e7c98f..b6d29593 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts @@ -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(), diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts index b4bdc303..bfc36af6 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts @@ -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, 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 543f807a..b09ddec3 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 @@ -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,