diff --git a/furniture-3d-system/docs/架构设计/供应链产品全量档案字段来源对照表.md b/furniture-3d-system/docs/架构设计/供应链产品全量档案字段来源对照表.md index 6c842807..5e8bb3f9 100644 --- a/furniture-3d-system/docs/架构设计/供应链产品全量档案字段来源对照表.md +++ b/furniture-3d-system/docs/架构设计/供应链产品全量档案字段来源对照表.md @@ -1,6 +1,6 @@ # 供应链产品全量档案字段来源对照表 -更新时间:2026-08-07 +更新时间:2026-08-10 ## 1. 合并与追踪规则 @@ -8,6 +8,11 @@ - 唯一合并键:`barcode` - 来源记录:`SupplyChainProductArchiveSource` - 字段级来源:`SupplyChainProductArchive.fieldSourceMap` +- 除图片外,产品全量档案页面使用的来源数据必须先写入 `SupplyChainProductArchive`;页面列表不再临时联查来源库。 +- 图片由素材库接口按条码/SKU获取;匹配结果的链接和来源信息保存在独立的 `SupplyChainProductArchiveImageCache` 表,不把图片二进制写进 PostgreSQL,也不进入商品修改日志。历史 `image` 值只作兼容兜底,不再由后续档案同步更新。 +- 首次建立商品档案只记录一条“商品档案导入”;后续来源值实际变化时,按条码、字段、旧值、新值和来源写入 `OperationLog`。 +- 扩展字段通过 `sync:supply-chain-product-archive-enrichment` 分批同步;重复执行时只有值发生变化的字段才更新主表并写日志。 +- 生产定时入口为 `scripts/install-supply-chain-product-archive-sync-timer.sh`,默认每 15 分钟先同步 `rpt.jst_sku` 基础档案,再同步供应商、仓库、生产周期、日销和价格等扩展字段。 - 聚水潭来源只读取 `JSReportReplica.rpt.jst_sku` 中 `item_type = '成品'` 的行。 - `jst_sku` 只更新本表明确列出的字段;空值不覆盖其他来源的已有值。 - 已由 `JST_SKU` 提供且有值的字段,不再被产品开发同步覆盖。 @@ -24,7 +29,7 @@ | `barcode` | 条码 | `rpt.jst_sku.sku_id` | `external.product_catalog_wide_v1.barcode_business_key` | | 去空格并转大写;档案唯一键 | | `spu` | SPU | `rpt.jst_sku.i_id` | `archive_spu` | | 去空格并转大写 | | `sku` | SKU | `rpt.jst_sku.other_7` | `archive_sku` | | 去空格并转大写;JST 空值不兜底 | -| `image` | 图片 | 暂不接入 | `image_url`,空时取 `archive_image` | | JST 图片待单独确认 | +| `image` | 图片 | | | 素材库接口 + `SupplyChainProductArchiveImageCache.previewUrl` | 条码优先、SKU 图兜底、最后白底图;成功链接长期复用,未命中 24 小时后可重试,不写档案主表 | | `businessCreatedAt` | 创建时间 | `rpt.jst_sku.created` | `product_development_created_at`,空时取 `source_created_at` | 云杉文件“创建时间”按条码匹配 | 按固定优先级覆盖 | | `categoryL1` | 一级类目 | | `sales_category_name` | `SupplyChainProductArchiveCategoryConfig.categoryL1` | JST 行按完整二级类目匹配配置表 | | `categoryL2` | 二级类目 | `rpt.jst_sku.category` | `archive_product_category_name` | | JST 原样保留完整类目文本 | @@ -43,13 +48,29 @@ | `installDocId` | 安装图纸/视频 ID | `rpt.jst_sku.other_9` | `drawing_id` | | 原样保留 | | `spec` | 产品尺寸 | 从 `rpt.jst_sku.name` 提取 | `archive_spec` | | 提取三轴尺寸,统一 `/` 分隔,保留小数和范围 | | `brand` | 品牌 | `rpt.jst_sku.brand` | `brand_name` | | 原样保留 | -| `orderType` | 叫货类型 | 暂不接入 | `purchase_order_method` | | | -| `storageType` | 入库/代发 | 暂不接入 | `archive_shipping_method` | | | +| `orderType` | 叫货类型 | `rpt.jst_sku.other_price_2` | `purchase_order_method` | | 按现有枚举归一 | +| `storageType` | 入库/代发 | | | `SupplyChainProductArchiveWarehouseConfig.storageType` | 按出入库仓库名称匹配配置表 | | `isSeasonal` | 是否季节性产品 | 暂无来源 | 暂无来源 | | 保持空值 | | `replaceCount` | 替换款数量 | 暂无来源 | 暂无来源 | | 保持空值 | | `supplierName` | 供应商 | `rpt.jst_sku.supplier_name` | `archive_supplier_name` | | 原样保留 | -| `purchaseOwner` | 采购 | 暂不接入 | `archive_purchase_owner` | | 只取流程采购负责人,不用产品负责人兜底 | -| `minimumOrderQuantity` | 产品起订量 | 暂不接入 | `archive_minimum_order_quantity` | | | +| `supplierType` | 供应商类型 | | | `rpt.js_supplier.scmtype` | 按供应商名称匹配后写主表 | +| `supplierStatus` | 供应商状态 | | | `rpt.js_supplier.coopersgn` | `1=合作`、`2=不合作` | +| `purchaseOwner` | 采购 | | `archive_purchase_owner` | `rpt.js_supplier.buyer` | 供应商采购非空时覆盖产品开发采购 | +| `settlementMethod` | 结算方式 | | | `rpt.js_supplier.paycyclesgn` | 按供应商名称匹配 | +| `boardProvider` | 板材提供方 | | | `rpt.js_supplier.undefined2` | 按供应商名称匹配 | +| `penaltyStatus` | 违约金赔付状态 | | | `rpt.js_supplier.undefined1` | 按供应商名称匹配 | +| `warehouseType` | 仓库属性 | | | `rpt.jst_warehouseInfo.remark2` | 按出入库仓库名称匹配 | +| `warehouseHead` | 仓库负责人 | | | `SupplyChainProductArchiveWarehouseConfig.warehouseHead` | 按出入库仓库名称匹配配置表 | +| `minimumOrderQuantity` | 产品起订量 | | `archive_minimum_order_quantity` | `rpt.pm_stockMonitor.minimum` | stock monitor 非空时覆盖产品开发起订量 | +| `firstLeadTime` | 首单生产周期 | | | `rpt.pm_stockMonitor.produceCycle` | 条码优先、SKU 唯一匹配兜底 | +| `palletSpec` | 托盘参数 | | | `rpt.pm_stockMonitor.trayParameter` | 条码优先、SKU 唯一匹配兜底 | +| `dailySales` | 采购日销 | | | `rpt.pm_stockMonitor.purchaseSale` | 按条码匹配最新记录 | +| `sales3d` | 3日日销 | | | `rpt.pm_stockMonitor.threeAvgSale` | 按条码匹配最新记录 | +| `sales7d` | 7日日销 | | | `rpt.pm_stockMonitor.sevenAvgSale` | 按条码匹配最新记录 | +| `sales15d` | 15日日销 | | | `rpt.pm_stockMonitor.fifteenAvgSale` | 按条码匹配最新记录 | +| `purchasePrice` | 采购价 | `rpt.jst_sku.purchase_price` | | | 按档案条码精确匹配 `jst_sku.sku_id`;匹配不到时留空 | +| `costPrice` | 成本价(聚水潭) | `rpt.jst_sku.cost_price` | | | 按档案条码精确匹配 `jst_sku.sku_id`;匹配不到时留空 | +| `costPriceSecondary` | 成本价(新品) | | `external.product_catalog_wide_v1.cost_price` | | 按档案条码精确匹配 `barcode_business_key`,只读取 `catalog_source = INTAKE_READY` 的新品行;匹配不到或来源为空时留空 | ## 3. JST_SKU 第一批字段覆盖率 @@ -82,4 +103,18 @@ | `JST_SKU` | 聚水潭 JSReportReplica | `rpt` | `jst_sku` | | `PRODUCT_DEVELOPMENT` | 家具产品开发系统 | `external` | `product_catalog_wide_v1` | | `CATEGORY_CONFIG` | 供应链中心类目配置 | `public` | `SupplyChainProductArchiveCategoryConfig` | +| `JST_SUPPLIER` | 聚水潭 JSReportReplica | `rpt` | `js_supplier` | +| `JST_STOCK_MONITOR` | 聚水潭 JSReportReplica | `rpt` | `pm_stockMonitor` | +| `JST_WAREHOUSE_INFO` | 聚水潭 JSReportReplica | `rpt` | `jst_warehouseInfo` | +| `JST_SKU_PRICE` | 聚水潭 JSReportReplica | `rpt` | `jst_sku` | +| `PRODUCT_CATALOG_COST_PRICE` | 家具产品开发系统 | `external` | `product_catalog_wide_v1` | +| `WAREHOUSE_CONFIG` | 供应链中心 | `public` | `SupplyChainProductArchiveWarehouseConfig` | | `YUNSHAN_PRODUCT_EXPORT` | 云杉商品资料 | Excel 工作表名 | Excel 文件名 | + +## 5. 生产同步入口 + +- 产品开发页面保存成功后,会按当前产品即时同步 `external.product_catalog_wide_v1` 的档案字段。 +- 聚水潭及其关联表由 systemd timer 周期检查。同步过程可以重复执行,主表只写实际变化,商品修改日志只记录后续真实变化。 +- 定时任务安装前必须在生产服务器的 `shared/supply-chain-product-archive-sync.env` 填写目标 PostgreSQL 和来源 SQL Server 的身份校验值;文件权限固定为 `600`,其中不保存数据库密码。 +- 安装命令只在发布完成并获得生产操作授权后执行:`bash scripts/install-supply-chain-product-archive-sync-timer.sh`。 +- 图片不属于此定时任务。页面优先读取 `SupplyChainProductArchiveImageCache`,没有缓存时才按条码/SKU调用素材库接口并回写链接。 diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json index 47b09893..4423d598 100644 --- a/furniture-3d-system/package.json +++ b/furniture-3d-system/package.json @@ -17,6 +17,7 @@ "prisma:push": "node scripts/prisma-db-push-local.js", "prisma:studio": "prisma studio", "sync:supply-chain-product-archive": "tsx scripts/sync-supply-chain-product-archive.ts", + "sync:supply-chain-product-archive-enrichment": "tsx scripts/sync-supply-chain-product-archive-enrichment.ts", "sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts", "import:yunshan-created-at": "tsx scripts/import-yunshan-product-created-at.ts", "import:product-archive-drawing-media": "tsx scripts/import-product-archive-drawing-media.ts", diff --git a/furniture-3d-system/prisma/migrations/20260810173000_persist_supply_chain_archive_enrichment/migration.sql b/furniture-3d-system/prisma/migrations/20260810173000_persist_supply_chain_archive_enrichment/migration.sql new file mode 100644 index 00000000..df286ce2 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260810173000_persist_supply_chain_archive_enrichment/migration.sql @@ -0,0 +1,17 @@ +ALTER TABLE "SupplyChainProductArchive" + ADD COLUMN "supplierType" TEXT NOT NULL DEFAULT '', + ADD COLUMN "supplierStatus" TEXT NOT NULL DEFAULT '', + ADD COLUMN "settlementMethod" TEXT NOT NULL DEFAULT '', + ADD COLUMN "boardProvider" TEXT NOT NULL DEFAULT '', + ADD COLUMN "penaltyStatus" TEXT NOT NULL DEFAULT '', + ADD COLUMN "warehouseType" TEXT NOT NULL DEFAULT '', + ADD COLUMN "warehouseHead" TEXT NOT NULL DEFAULT '', + ADD COLUMN "firstLeadTime" TEXT NOT NULL DEFAULT '', + ADD COLUMN "palletSpec" TEXT NOT NULL DEFAULT '', + ADD COLUMN "dailySales" TEXT NOT NULL DEFAULT '', + ADD COLUMN "sales3d" TEXT NOT NULL DEFAULT '', + ADD COLUMN "sales7d" TEXT NOT NULL DEFAULT '', + ADD COLUMN "sales15d" TEXT NOT NULL DEFAULT '', + ADD COLUMN "purchasePrice" TEXT NOT NULL DEFAULT '', + ADD COLUMN "costPrice" TEXT NOT NULL DEFAULT '', + ADD COLUMN "costPriceSecondary" TEXT NOT NULL DEFAULT ''; diff --git a/furniture-3d-system/prisma/migrations/20260811100000_add_supply_chain_product_archive_image_cache/migration.sql b/furniture-3d-system/prisma/migrations/20260811100000_add_supply_chain_product_archive_image_cache/migration.sql new file mode 100644 index 00000000..6647053b --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260811100000_add_supply_chain_product_archive_image_cache/migration.sql @@ -0,0 +1,35 @@ +CREATE TABLE "SupplyChainProductArchiveImageCache" ( + "id" TEXT NOT NULL, + "archiveId" TEXT NOT NULL, + "barcode" TEXT NOT NULL DEFAULT '', + "spu" TEXT NOT NULL DEFAULT '', + "sku" TEXT NOT NULL DEFAULT '', + "previewUrl" TEXT, + "source" TEXT NOT NULL DEFAULT '', + "viewKey" TEXT NOT NULL DEFAULT '', + "viewLabel" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'NOT_FOUND', + "checkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "error" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SupplyChainProductArchiveImageCache_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "SupplyChainProductArchiveImageCache_archiveId_key" +ON "SupplyChainProductArchiveImageCache"("archiveId"); + +CREATE INDEX "SupplyChainProductArchiveImageCache_barcode_idx" +ON "SupplyChainProductArchiveImageCache"("barcode"); + +CREATE INDEX "SupplyChainProductArchiveImageCache_spu_sku_idx" +ON "SupplyChainProductArchiveImageCache"("spu", "sku"); + +CREATE INDEX "SupplyChainProductArchiveImageCache_status_checkedAt_idx" +ON "SupplyChainProductArchiveImageCache"("status", "checkedAt"); + +ALTER TABLE "SupplyChainProductArchiveImageCache" +ADD CONSTRAINT "SupplyChainProductArchiveImageCache_archiveId_fkey" +FOREIGN KEY ("archiveId") REFERENCES "SupplyChainProductArchive"("id") +ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 3c480527..3387a663 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -481,11 +481,28 @@ model SupplyChainProductArchive { replaceCount String @default("") supplierName String @default("") purchaseOwner String @default("") + supplierType String @default("") + supplierStatus String @default("") minimumOrderQuantity String @default("") + settlementMethod String @default("") + boardProvider String @default("") + penaltyStatus String @default("") + warehouseType String @default("") + warehouseHead String @default("") firstSourceCode String @default("PRODUCT_DEVELOPMENT") + firstLeadTime String @default("") + palletSpec String @default("") + dailySales String @default("") + sales3d String @default("") + sales7d String @default("") + sales15d String @default("") + purchasePrice String @default("") + costPrice String @default("") + costPriceSecondary String @default("") fieldSourceMap Json @default("{}") sources SupplyChainProductArchiveSource[] imageEmbedding ProductArchiveImageEmbedding? + imageCache SupplyChainProductArchiveImageCache? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -500,6 +517,28 @@ model SupplyChainProductArchive { } // 图纸与视频导出来源:按条码保存当前有效的图纸/视频状态,支持月度增量覆盖。 +model SupplyChainProductArchiveImageCache { + id String @id @default(cuid()) + archiveId String @unique + archive SupplyChainProductArchive @relation(fields: [archiveId], references: [id], onDelete: Cascade) + barcode String @default("") + spu String @default("") + sku String @default("") + previewUrl String? @db.Text + source String @default("") + viewKey String @default("") + viewLabel String @default("") + status String @default("NOT_FOUND") + checkedAt DateTime @default(now()) + error String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([barcode]) + @@index([spu, sku]) + @@index([status, checkedAt]) +} + model SupplyChainProductArchiveDrawingMedia { id String @id @default(cuid()) barcode String @unique diff --git a/furniture-3d-system/scripts/install-supply-chain-product-archive-sync-timer.sh b/furniture-3d-system/scripts/install-supply-chain-product-archive-sync-timer.sh new file mode 100644 index 00000000..b0a10bf9 --- /dev/null +++ b/furniture-3d-system/scripts/install-supply-chain-product-archive-sync-timer.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}" +SERVICE_NAME="${SUPPLY_CHAIN_ARCHIVE_SYNC_SERVICE_NAME:-furniture-supply-chain-product-archive-sync}" +APP_ROOT="$BASE/current/furniture-3d-system" +SHARED="$BASE/shared" +APP_ENV="$SHARED/.env" +SYNC_ENV="$SHARED/supply-chain-product-archive-sync.env" +SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" +TIMER_FILE="/etc/systemd/system/$SERVICE_NAME.timer" +SYNC_ON_BOOT_SEC="${SUPPLY_CHAIN_ARCHIVE_SYNC_ON_BOOT_SEC:-3min}" +SYNC_INTERVAL="${SUPPLY_CHAIN_ARCHIVE_SYNC_INTERVAL:-15min}" + +if [ "$(id -u)" -ne 0 ]; then + echo "This installer must run as root." >&2 + exit 1 +fi + +if [ ! -d "$APP_ROOT" ]; then + echo "Missing app root: $APP_ROOT" >&2 + exit 1 +fi + +if [ ! -f "$APP_ENV" ]; then + echo "Missing app env: $APP_ENV" >&2 + exit 1 +fi + +mkdir -p "$SHARED" +if [ ! -f "$SYNC_ENV" ]; then + umask 077 + cat >"$SYNC_ENV" <<'EOF' +# Product archive source sync guard identities. +# Fill these values on the production server. Do not commit secrets to Git. +SUPPLY_CHAIN_ARCHIVE_EXPECTED_DATABASE_IDENTITY= +SUPPLY_CHAIN_ARCHIVE_EXPECTED_SOURCE_DATABASE_IDENTITY= +SUPPLY_CHAIN_ARCHIVE_ENRICHMENT_BATCH_SIZE=200 +SUPPLY_CHAIN_ARCHIVE_JST_BATCH_SIZE=500 +SUPPLY_CHAIN_ARCHIVE_JST_CONCURRENCY=8 +EOF + echo "Created env template: $SYNC_ENV" +fi + +chmod 600 "$SYNC_ENV" + +cat >"$SERVICE_FILE" <"$TIMER_FILE" < argument.startsWith(prefix))?.slice(prefix.length) ?? '' +} + +function databaseIdentity(databaseUrl: string) { + const parsed = new URL(databaseUrl) + return [ + parsed.protocol.replace(/:$/, ''), + decodeURIComponent(parsed.username || ''), + parsed.hostname, + parsed.port || '', + parsed.pathname.replace(/^\//, ''), + parsed.searchParams.get('schema') ?? '', + ].join('|') +} + +function sourceDatabaseIdentity() { + const host = process.env.JST_REPORT_DB_HOST?.trim() ?? '' + const database = process.env.JST_REPORT_DB_NAME?.trim() ?? '' + const user = process.env.JST_REPORT_DB_USER?.trim() ?? '' + if (!host || !database || !user) return '' + return [ + 'sqlserver', + user, + host, + process.env.JST_REPORT_DB_PORT?.trim() || '1433', + database, + process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt', + ].join('|') +} + +async function main() { + if (!process.argv.includes('--apply')) { + throw new Error('This sync writes the archive. Pass --apply explicitly.') + } + if (readArgument('confirm') !== CONFIRM_TOKEN) { + throw new Error(`Pass --confirm=${CONFIRM_TOKEN}.`) + } + + const databaseUrl = process.env.DATABASE_URL?.trim() ?? '' + if (!databaseUrl) throw new Error('DATABASE_URL is required.') + const actualDatabaseIdentity = databaseIdentity(databaseUrl) + if (readArgument('expected-database-identity') !== actualDatabaseIdentity) { + throw new Error( + `Pass --expected-database-identity=${actualDatabaseIdentity}.`, + ) + } + + const actualSourceIdentity = sourceDatabaseIdentity() + if (!actualSourceIdentity) throw new Error('JST_REPORT_DB_* is required.') + if (readArgument('expected-source-database-identity') !== actualSourceIdentity) { + throw new Error( + `Pass --expected-source-database-identity=${actualSourceIdentity}.`, + ) + } + + const batchSizeValue = Number(readArgument('batch-size') || '200') + if (!Number.isSafeInteger(batchSizeValue) || batchSizeValue < 1 || batchSizeValue > 200) { + throw new Error('--batch-size must be an integer between 1 and 200.') + } + + const syncJstBase = process.argv.includes('--sync-jst-base') + const jstBatchSize = Number(readArgument('jst-batch-size') || '500') + if ( + !Number.isSafeInteger(jstBatchSize) || + jstBatchSize < 1 || + jstBatchSize > 5_000 + ) { + throw new Error('--jst-batch-size must be an integer between 1 and 5000.') + } + const jstConcurrency = Number(readArgument('jst-concurrency') || '8') + if ( + !Number.isSafeInteger(jstConcurrency) || + jstConcurrency < 1 || + jstConcurrency > 32 + ) { + throw new Error('--jst-concurrency must be an integer between 1 and 32.') + } + + const [ + { prisma }, + { supplyChainProductArchiveEnrichmentSyncService }, + { jstSkuProductArchiveFullSyncService }, + ] = await Promise.all([ + import('../src/lib/prisma'), + import('../src/server/services/supply-chain-product-archive-enrichment-sync.service'), + syncJstBase + ? import('../src/server/services/jst-sku-product-archive-full-sync.service') + : Promise.resolve({ jstSkuProductArchiveFullSyncService: null }), + ]) + try { + const baseResult = jstSkuProductArchiveFullSyncService + ? await jstSkuProductArchiveFullSyncService.syncAll({ + apply: true, + batchSize: jstBatchSize, + concurrency: jstConcurrency, + }) + : null + const enrichmentResult = await supplyChainProductArchiveEnrichmentSyncService.syncAll({ + batchSize: batchSizeValue, + }) + console.log(JSON.stringify({ baseResult, enrichmentResult }, null, 2)) + } finally { + await prisma.$disconnect() + } +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/src/app/api/supply-chain/product-archive/material-images/route.ts b/furniture-3d-system/src/app/api/supply-chain/product-archive/material-images/route.ts new file mode 100644 index 00000000..b6088f12 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/product-archive/material-images/route.ts @@ -0,0 +1,47 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productArchiveMaterialImageService } from '@/server/services/product-archive-material-image.service' + +const batchItemSchema = z.object({ + archiveId: z.string().trim().min(1).max(120), + spu: z.string().trim().max(120).optional(), + barcode: z.string().trim().max(120).optional(), + sku: z.string().trim().max(120).optional(), +}).refine((value) => Boolean(value.barcode || value.sku), { + message: '条码和 SKU 至少提供一个', +}) + +const batchSchema = z.object({ + items: z.array(batchItemSchema).min(1).max(200), +}).strict() + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = batchSchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '当前页图片匹配参数不正确', + 400, + ctx.requestId, + ) + } + + const result = await productArchiveMaterialImageService.resolveBatch( + parsed.data.items, + request.signal, + ) + return jsonSuccess(result, 200, ctx.requestId) + }, + { + requireAuth: true, + allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], + allowedBusinessRoles: ['MANAGEMENT'], + }, + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveMaterialImageCell.tsx b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveMaterialImageCell.tsx index 908eb7f7..d5db0dd2 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveMaterialImageCell.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveMaterialImageCell.tsx @@ -2,37 +2,32 @@ import Image from 'next/image' import { useEffect, useMemo, useRef, useState } from 'react' -import type { ProductArchiveMaterialImage } from '@/lib/supply-chain-product-archive-material-image' import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive' -import type { ApiResponse } from '@/server/core/types' +import type { ProductArchivePageImageState } from './useProductArchivePageImages' export function ProductArchiveMaterialImageCell({ row, + imageState, + onVisible, onPreviewImage, }: { row: SupplyChainProductArchiveRow + imageState: ProductArchivePageImageState + onVisible: (archiveId: string) => void onPreviewImage: (image: string, title: string) => void }) { const anchorRef = useRef(null) const [inView, setInView] = useState(false) - const [resolvedImage, setResolvedImage] = useState(null) - const [unavailable, setUnavailable] = useState(false) - const [sourceImageUnavailable, setSourceImageUnavailable] = useState(false) + const [failedMaterialImageUrl, setFailedMaterialImageUrl] = useState('') + const [failedSourceImageUrl, setFailedSourceImageUrl] = useState('') - const lookupUrl = useMemo(() => { - if (!row.spu || (!row.barcode && !row.sku)) return '' - const params = new URLSearchParams() - params.set('spu', row.spu) - if (row.barcode) params.set('barcode', row.barcode) - if (row.sku) params.set('sku', row.sku) - return `/api/supply-chain/product-archive/material-image?${params.toString()}` - }, [row.barcode, row.sku, row.spu]) + const canResolveMaterialImage = Boolean(row.spu && (row.barcode || row.sku)) const sourceImageUrl = useMemo(() => { if (!row.image || !row.barcode) return '' const params = new URLSearchParams({ barcode: row.barcode }) return `/api/supply-chain/product-archive/source-image?${params.toString()}` }, [row.barcode, row.image]) - const canResolveImage = Boolean(lookupUrl || sourceImageUrl) + const canResolveImage = Boolean(canResolveMaterialImage || sourceImageUrl) useEffect(() => { if (!canResolveImage || inView) return @@ -56,35 +51,27 @@ export function ProductArchiveMaterialImageCell({ }, [canResolveImage, inView]) useEffect(() => { - if (!lookupUrl || !inView || unavailable || resolvedImage) return - const controller = new AbortController() + if (inView && canResolveMaterialImage) onVisible(row.id) + }, [canResolveMaterialImage, inView, onVisible, row.id]) - async function resolveImage() { - try { - const response = await fetch(lookupUrl, { signal: controller.signal }) - const payload = (await response.json()) as ApiResponse - if (!response.ok || !payload.success || !payload.data) { - setUnavailable(true) - return - } - setResolvedImage(payload.data) - } catch { - if (!controller.signal.aborted) setUnavailable(true) - } - } - - void resolveImage() - return () => controller.abort() - }, [inView, lookupUrl, resolvedImage, unavailable]) - - const useSourceImage = !resolvedImage && (unavailable || !lookupUrl) - const imageUrl = resolvedImage?.previewUrl - || (useSourceImage && !sourceImageUnavailable ? sourceImageUrl : '') + const resolvedImage = imageState.status === 'resolved' + && imageState.image?.previewUrl !== failedMaterialImageUrl + ? imageState.image + : null + const materialLookupSettled = !canResolveMaterialImage + || imageState.status === 'not-found' + || imageState.status === 'error' + || Boolean(failedMaterialImageUrl) + const useSourceImage = !resolvedImage && materialLookupSettled + const imageUrl = inView + ? resolvedImage?.previewUrl + || (useSourceImage && sourceImageUrl !== failedSourceImageUrl ? sourceImageUrl : '') + : '' if (!imageUrl) { return (